Skip to content

Commit 1d5ba4f

Browse files
NJichevmhanberg
andauthored
feat: add require code action (#375)
* feat: add require code action This adds a require code action that adds `require Module` to your module whenever a macro is used without requiring it beforehand. It tries to insert the require after all the top level Elixir macros(moduledoc, alias, require, import). * Refactor indent clause * Fix formatting * Refactor module name with &Macro.to_string/1 * reword title --------- Co-authored-by: Mitchell Hanberg <[email protected]>
1 parent 5096334 commit 1d5ba4f

File tree

6 files changed

+391
-8
lines changed

6 files changed

+391
-8
lines changed

lib/next_ls/extensions/elixir_extension.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,17 @@ defmodule NextLS.ElixirExtension do
114114
def clamp(line), do: max(line, 0)
115115

116116
@unused_variable ~r/variable\s\"[^\"]+\"\sis\sunused/
117+
@require_module ~r/you\smust\srequire/
117118
defp metadata(diagnostic) do
118119
base = %{"namespace" => "elixir"}
119120

120121
cond do
121122
is_binary(diagnostic.message) and diagnostic.message =~ @unused_variable ->
122123
Map.put(base, "type", "unused_variable")
123124

125+
is_binary(diagnostic.message) and diagnostic.message =~ @require_module ->
126+
Map.put(base, "type", "require")
127+
124128
true ->
125129
base
126130
end

lib/next_ls/extensions/elixir_extension/code_action.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ defmodule NextLS.ElixirExtension.CodeAction do
44
@behaviour NextLS.CodeActionable
55

66
alias NextLS.CodeActionable.Data
7+
alias NextLS.ElixirExtension.CodeAction.Require
78
alias NextLS.ElixirExtension.CodeAction.UnusedVariable
89

910
@impl true
@@ -12,6 +13,9 @@ defmodule NextLS.ElixirExtension.CodeAction do
1213
%{"type" => "unused_variable"} ->
1314
UnusedVariable.new(data.diagnostic, data.document, data.uri)
1415

16+
%{"type" => "require"} ->
17+
Require.new(data.diagnostic, data.document, data.uri)
18+
1519
_ ->
1620
[]
1721
end
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
defmodule NextLS.ElixirExtension.CodeAction.Require do
2+
@moduledoc false
3+
4+
alias GenLSP.Structures.CodeAction
5+
alias GenLSP.Structures.Diagnostic
6+
alias GenLSP.Structures.Position
7+
alias GenLSP.Structures.Range
8+
alias GenLSP.Structures.TextEdit
9+
alias GenLSP.Structures.WorkspaceEdit
10+
11+
@one_indentation_level " "
12+
@spec new(diagnostic :: Diagnostic.t(), [text :: String.t()], uri :: String.t()) :: [CodeAction.t()]
13+
def new(%Diagnostic{} = diagnostic, text, uri) do
14+
range = diagnostic.range
15+
16+
with {:ok, require_module} <- get_edit(diagnostic.message),
17+
{:ok, ast} <- parse_ast(text),
18+
{:ok, defm} <- nearest_defmodule(ast, range),
19+
indentation <- get_indent(text, defm),
20+
nearest <- find_nearest_node_for_require(defm),
21+
range <- get_edit_range(nearest) do
22+
[
23+
%CodeAction{
24+
title: "Add missing require for #{require_module}",
25+
diagnostics: [diagnostic],
26+
edit: %WorkspaceEdit{
27+
changes: %{
28+
uri => [
29+
%TextEdit{
30+
new_text: indentation <> "require #{require_module}\n",
31+
range: range
32+
}
33+
]
34+
}
35+
}
36+
}
37+
]
38+
else
39+
_error ->
40+
[]
41+
end
42+
end
43+
44+
defp parse_ast(text) do
45+
text
46+
|> Enum.join("\n")
47+
|> Spitfire.parse()
48+
end
49+
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+
71+
@module_name ~r/require\s+([^\s]+)\s+before/
72+
defp get_edit(message) do
73+
case Regex.run(@module_name, message) do
74+
[_, module] -> {:ok, module}
75+
_ -> {:error, "unable to find require"}
76+
end
77+
end
78+
79+
# Context starts from 1 while LSP starts from 0
80+
# which works for us since we want to insert the require on the next line
81+
defp get_edit_range(context) do
82+
%Range{
83+
start: %Position{line: context[:line], character: 0},
84+
end: %Position{line: context[:line], character: 0}
85+
}
86+
end
87+
88+
@indent ~r/^(\s*).*/
89+
defp get_indent(text, {_, defm_context, _}) do
90+
line = defm_context[:line] - 1
91+
92+
indent =
93+
text
94+
|> Enum.at(line)
95+
|> then(&Regex.run(@indent, &1))
96+
|> List.last()
97+
98+
indent <> @one_indentation_level
99+
end
100+
101+
@top_level_macros [:import, :alias, :require]
102+
defp find_nearest_node_for_require({:defmodule, context, _} = ast) do
103+
top_level_macros =
104+
ast
105+
|> Macro.prewalker()
106+
|> Enum.filter(fn
107+
{:@, _, [{:moduledoc, _, _}]} -> true
108+
{macro, _, _} when macro in @top_level_macros -> true
109+
_ -> false
110+
end)
111+
112+
case top_level_macros do
113+
[] ->
114+
context
115+
116+
_ ->
117+
{_, context, _} = Enum.max_by(top_level_macros, fn {_, ctx, _} -> ctx[:line] end)
118+
context
119+
end
120+
end
121+
end
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
defmodule NextLS.ElixirExtension.RequireTest do
2+
use ExUnit.Case, async: true
3+
4+
alias GenLSP.Structures.CodeAction
5+
alias GenLSP.Structures.Position
6+
alias GenLSP.Structures.Range
7+
alias GenLSP.Structures.TextEdit
8+
alias GenLSP.Structures.WorkspaceEdit
9+
alias NextLS.ElixirExtension.CodeAction.Require
10+
11+
test "adds require to module" do
12+
text =
13+
String.split(
14+
"""
15+
defmodule Test.Require do
16+
def hello() do
17+
Logger.info("foo")
18+
end
19+
end
20+
""",
21+
"\n"
22+
)
23+
24+
start = %Position{character: 0, line: 1}
25+
26+
diagnostic = %GenLSP.Structures.Diagnostic{
27+
data: %{"namespace" => "elixir", "type" => "require"},
28+
message: "you must require Logger before invoking the macro Logger.info/1",
29+
source: "Elixir",
30+
range: %GenLSP.Structures.Range{
31+
start: start,
32+
end: %{start | character: 999}
33+
}
34+
}
35+
36+
uri = "file:///home/owner/my_project/hello.ex"
37+
38+
assert [code_action] = Require.new(diagnostic, text, uri)
39+
assert is_struct(code_action, CodeAction)
40+
assert [diagnostic] == code_action.diagnostics
41+
assert code_action.title == "Add missing require for Logger"
42+
43+
assert %WorkspaceEdit{
44+
changes: %{
45+
^uri => [
46+
%TextEdit{
47+
new_text: " require Logger\n",
48+
range: %Range{start: ^start, end: ^start}
49+
}
50+
]
51+
}
52+
} = code_action.edit
53+
end
54+
55+
test "adds require after moduledoc" do
56+
text =
57+
String.split(
58+
"""
59+
defmodule Test.Require do
60+
@moduledoc
61+
def hello() do
62+
Logger.info("foo")
63+
end
64+
end
65+
""",
66+
"\n"
67+
)
68+
69+
start = %Position{character: 0, line: 2}
70+
71+
diagnostic = %GenLSP.Structures.Diagnostic{
72+
data: %{"namespace" => "elixir", "type" => "require"},
73+
message: "you must require Logger before invoking the macro Logger.info/1",
74+
source: "Elixir",
75+
range: %GenLSP.Structures.Range{
76+
start: start,
77+
end: %{start | character: 999}
78+
}
79+
}
80+
81+
uri = "file:///home/owner/my_project/hello.ex"
82+
83+
assert [code_action] = Require.new(diagnostic, text, uri)
84+
assert is_struct(code_action, CodeAction)
85+
assert [diagnostic] == code_action.diagnostics
86+
assert code_action.title == "Add missing require for Logger"
87+
88+
assert %WorkspaceEdit{
89+
changes: %{
90+
^uri => [
91+
%TextEdit{
92+
new_text: " require Logger\n",
93+
range: %Range{start: ^start, end: ^start}
94+
}
95+
]
96+
}
97+
} = code_action.edit
98+
end
99+
100+
test "adds require after alias" do
101+
text =
102+
String.split(
103+
"""
104+
defmodule Test.Require do
105+
@moduledoc
106+
import Test.Foo
107+
alias Test.Bar
108+
def hello() do
109+
Logger.info("foo")
110+
end
111+
end
112+
""",
113+
"\n"
114+
)
115+
116+
start = %Position{character: 0, line: 4}
117+
118+
diagnostic = %GenLSP.Structures.Diagnostic{
119+
data: %{"namespace" => "elixir", "type" => "require"},
120+
message: "you must require Logger before invoking the macro Logger.info/1",
121+
source: "Elixir",
122+
range: %GenLSP.Structures.Range{
123+
start: start,
124+
end: %{start | character: 999}
125+
}
126+
}
127+
128+
uri = "file:///home/owner/my_project/hello.ex"
129+
130+
assert [code_action] = Require.new(diagnostic, text, uri)
131+
assert is_struct(code_action, CodeAction)
132+
assert [diagnostic] == code_action.diagnostics
133+
assert code_action.title == "Add missing require for Logger"
134+
135+
assert %WorkspaceEdit{
136+
changes: %{
137+
^uri => [
138+
%TextEdit{
139+
new_text: " require Logger\n",
140+
range: %Range{start: ^start, end: ^start}
141+
}
142+
]
143+
}
144+
} = code_action.edit
145+
end
146+
147+
test "figures out the correct module" do
148+
text =
149+
String.split(
150+
"""
151+
defmodule Test do
152+
defmodule Foo do
153+
def hello() do
154+
IO.inspect("foo")
155+
end
156+
end
157+
158+
defmodule Require do
159+
@moduledoc
160+
import Test.Foo
161+
alias Test.Bar
162+
163+
def hello() do
164+
Logger.info("foo")
165+
end
166+
end
167+
end
168+
""",
169+
"\n"
170+
)
171+
172+
start = %Position{character: 0, line: 11}
173+
174+
diagnostic = %GenLSP.Structures.Diagnostic{
175+
data: %{"namespace" => "elixir", "type" => "require"},
176+
message: "you must require Logger before invoking the macro Logger.info/1",
177+
source: "Elixir",
178+
range: %GenLSP.Structures.Range{
179+
start: start,
180+
end: %{start | character: 999}
181+
}
182+
}
183+
184+
uri = "file:///home/owner/my_project/hello.ex"
185+
186+
assert [code_action] = Require.new(diagnostic, text, uri)
187+
assert is_struct(code_action, CodeAction)
188+
assert [diagnostic] == code_action.diagnostics
189+
assert code_action.title == "Add missing require for Logger"
190+
191+
assert %WorkspaceEdit{
192+
changes: %{
193+
^uri => [
194+
%TextEdit{
195+
new_text: " require Logger\n",
196+
range: %Range{start: ^start, end: ^start}
197+
}
198+
]
199+
}
200+
} = code_action.edit
201+
end
202+
end

test/next_ls/extensions/elixir_extension/code_action/unused_variable_test.exs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@ defmodule NextLS.ElixirExtension.UnusedVariableTest do
99
alias NextLS.ElixirExtension.CodeAction.UnusedVariable
1010

1111
test "adds an underscore to unused variables" do
12-
text = """
13-
defmodule Test.Unused do
14-
def hello() do
15-
foo = 3
16-
:world
17-
end
18-
end
19-
"""
12+
text =
13+
String.split(
14+
"""
15+
defmodule Test.Unused do
16+
def hello() do
17+
foo = 3
18+
:world
19+
end
20+
end
21+
""",
22+
"\n"
23+
)
2024

2125
start = %Position{character: 4, line: 3}
2226

0 commit comments

Comments
 (0)