Skip to content

Commit d03c1ad

Browse files
authored
feat: undefined function code action (#441)
When you have an undefined local function, this code action allows you to create a private function stub for the function.
1 parent 9c2ff68 commit d03c1ad

File tree

5 files changed

+261
-10
lines changed

5 files changed

+261
-10
lines changed

lib/next_ls/extensions/elixir_extension.ex

+5-8
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,6 @@ defmodule NextLS.ElixirExtension do
3232
DiagnosticCache.clear(state.cache, :elixir)
3333

3434
for d <- diagnostics do
35-
# TODO: some compiler diagnostics only have the line number
36-
# but we want to only highlight the source code, so we
37-
# need to read the text of the file (either from the lsp cache
38-
# if the source code is "open", or read from disk) and calculate the
39-
# column of the first non-whitespace character.
40-
#
41-
# it is not clear to me whether the LSP process or the extension should
42-
# be responsible for this. The open documents live in the LSP process
4335
DiagnosticCache.put(state.cache, :elixir, d.file, %GenLSP.Structures.Diagnostic{
4436
severity: severity(d.severity),
4537
message: IO.iodata_to_binary(d.message),
@@ -115,6 +107,7 @@ defmodule NextLS.ElixirExtension do
115107

116108
@unused_variable ~r/variable\s\"[^\"]+\"\sis\sunused/
117109
@require_module ~r/you\smust\srequire/
110+
@undefined_local_function ~r/undefined function (?<name>.*)\/(?<arity>\d) \(expected (?<module>.*) to define such a function or for it to be imported, but none are available\)/
118111
defp metadata(diagnostic) do
119112
base = %{"namespace" => "elixir"}
120113

@@ -125,6 +118,10 @@ defmodule NextLS.ElixirExtension do
125118
is_binary(diagnostic.message) and diagnostic.message =~ @require_module ->
126119
Map.put(base, "type", "require")
127120

121+
is_binary(diagnostic.message) and diagnostic.message =~ @undefined_local_function ->
122+
info = Regex.named_captures(@undefined_local_function, diagnostic.message)
123+
base |> Map.put("type", "undefined-function") |> Map.put("info", info)
124+
128125
true ->
129126
base
130127
end

lib/next_ls/extensions/elixir_extension/code_action.ex

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule NextLS.ElixirExtension.CodeAction do
55

66
alias NextLS.CodeActionable.Data
77
alias NextLS.ElixirExtension.CodeAction.Require
8+
alias NextLS.ElixirExtension.CodeAction.UndefinedFunction
89
alias NextLS.ElixirExtension.CodeAction.UnusedVariable
910

1011
@impl true
@@ -16,6 +17,9 @@ defmodule NextLS.ElixirExtension.CodeAction do
1617
%{"type" => "require"} ->
1718
Require.new(data.diagnostic, data.document, data.uri)
1819

20+
%{"type" => "undefined-function"} ->
21+
UndefinedFunction.new(data.diagnostic, data.document, data.uri)
22+
1923
_ ->
2024
[]
2125
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
defmodule NextLS.ElixirExtension.CodeAction.UndefinedFunction do
2+
@moduledoc false
3+
4+
alias GenLSP.Structures.CodeAction
5+
alias GenLSP.Structures.Diagnostic
6+
alias GenLSP.Structures.Range
7+
alias GenLSP.Structures.TextEdit
8+
alias GenLSP.Structures.WorkspaceEdit
9+
alias NextLS.ASTHelpers
10+
11+
def new(diagnostic, text, uri) do
12+
%Diagnostic{range: range, data: %{"info" => %{"name" => name, "arity" => arity}}} = diagnostic
13+
14+
with {:ok, ast} <-
15+
text
16+
|> Enum.join("\n")
17+
|> Spitfire.parse(literal_encoder: &{:ok, {:__block__, &2, [&1]}}),
18+
{:ok, {:defmodule, meta, _} = defm} <- ASTHelpers.get_surrounding_module(ast, range.start) do
19+
indentation = get_indent(text, defm)
20+
21+
position = %GenLSP.Structures.Position{
22+
line: meta[:end][:line] - 1,
23+
character: 0
24+
}
25+
26+
params = if arity == "0", do: "", else: Enum.map_join(1..String.to_integer(arity), ", ", fn i -> "param#{i}" end)
27+
28+
action = fn title, new_text ->
29+
%CodeAction{
30+
title: title,
31+
diagnostics: [diagnostic],
32+
edit: %WorkspaceEdit{
33+
changes: %{
34+
uri => [
35+
%TextEdit{
36+
new_text: new_text,
37+
range: %Range{
38+
start: position,
39+
end: position
40+
}
41+
}
42+
]
43+
}
44+
}
45+
}
46+
end
47+
48+
[
49+
action.("Create public function #{name}/#{arity}", """
50+
51+
#{indentation}def #{name}(#{params}) do
52+
53+
#{indentation}end
54+
"""),
55+
action.("Create private function #{name}/#{arity}", """
56+
57+
#{indentation}defp #{name}(#{params}) do
58+
59+
#{indentation}end
60+
""")
61+
]
62+
end
63+
end
64+
65+
@one_indentation_level " "
66+
@indent ~r/^(\s*).*/
67+
defp get_indent(text, {_, defm_context, _}) do
68+
line = defm_context[:line] - 1
69+
70+
indent =
71+
text
72+
|> Enum.at(line)
73+
|> then(&Regex.run(@indent, &1))
74+
|> List.last()
75+
76+
indent <> @one_indentation_level
77+
end
78+
end

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

+4-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ defmodule NextLS.ElixirExtension.RequireTest do
2121
"\n"
2222
)
2323

24-
start = %Position{character: 0, line: 1}
24+
start = %Position{character: 11, line: 2}
2525

2626
diagnostic = %GenLSP.Structures.Diagnostic{
2727
data: %{"namespace" => "elixir", "type" => "require"},
@@ -40,12 +40,14 @@ defmodule NextLS.ElixirExtension.RequireTest do
4040
assert [diagnostic] == code_action.diagnostics
4141
assert code_action.title == "Add missing require for Logger"
4242

43+
edit_position = %GenLSP.Structures.Position{line: 1, character: 0}
44+
4345
assert %WorkspaceEdit{
4446
changes: %{
4547
^uri => [
4648
%TextEdit{
4749
new_text: " require Logger\n",
48-
range: %Range{start: ^start, end: ^start}
50+
range: %Range{start: ^edit_position, end: ^edit_position}
4951
}
5052
]
5153
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
defmodule NextLS.ElixirExtension.UndefinedFunctionTest 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.UndefinedFunction
10+
11+
test "in outer module creates new private function inside current module" do
12+
text =
13+
String.split(
14+
"""
15+
defmodule Test.Foo do
16+
defmodule Bar do
17+
def run() do
18+
:ok
19+
end
20+
end
21+
22+
def hello() do
23+
bar(1, 2)
24+
end
25+
26+
defmodule Baz do
27+
def run() do
28+
:error
29+
end
30+
end
31+
end
32+
""",
33+
"\n"
34+
)
35+
36+
start = %Position{character: 4, line: 8}
37+
38+
diagnostic = %GenLSP.Structures.Diagnostic{
39+
data: %{
40+
"namespace" => "elixir",
41+
"type" => "undefined-function",
42+
"info" => %{
43+
"name" => "bar",
44+
"arity" => "2",
45+
"module" => "Elixir.Test.Foo"
46+
}
47+
},
48+
message:
49+
"undefined function bar/2 (expected Test.Foo to define such a function or for it to be imported, but none are available)",
50+
source: "Elixir",
51+
range: %GenLSP.Structures.Range{
52+
start: start,
53+
end: %{start | character: 6}
54+
}
55+
}
56+
57+
uri = "file:///home/owner/my_project/hello.ex"
58+
59+
assert [public, private] = UndefinedFunction.new(diagnostic, text, uri)
60+
assert [diagnostic] == public.diagnostics
61+
assert public.title == "Create public function bar/2"
62+
63+
edit_position = %Position{line: 16, character: 0}
64+
65+
assert %WorkspaceEdit{
66+
changes: %{
67+
^uri => [
68+
%TextEdit{
69+
new_text: """
70+
71+
def bar(param1, param2) do
72+
73+
end
74+
""",
75+
range: %Range{start: ^edit_position, end: ^edit_position}
76+
}
77+
]
78+
}
79+
} = public.edit
80+
81+
assert [diagnostic] == private.diagnostics
82+
assert private.title == "Create private function bar/2"
83+
84+
edit_position = %Position{line: 16, character: 0}
85+
86+
assert %WorkspaceEdit{
87+
changes: %{
88+
^uri => [
89+
%TextEdit{
90+
new_text: """
91+
92+
defp bar(param1, param2) do
93+
94+
end
95+
""",
96+
range: %Range{start: ^edit_position, end: ^edit_position}
97+
}
98+
]
99+
}
100+
} = private.edit
101+
end
102+
103+
test "in inner module creates new private function inside current module" do
104+
text =
105+
String.split(
106+
"""
107+
defmodule Test.Foo do
108+
defmodule Bar do
109+
def run() do
110+
bar(1, 2)
111+
end
112+
end
113+
114+
defmodule Baz do
115+
def run() do
116+
:error
117+
end
118+
end
119+
end
120+
""",
121+
"\n"
122+
)
123+
124+
start = %Position{character: 6, line: 3}
125+
126+
diagnostic = %GenLSP.Structures.Diagnostic{
127+
data: %{
128+
"namespace" => "elixir",
129+
"type" => "undefined-function",
130+
"info" => %{
131+
"name" => "bar",
132+
"arity" => "2",
133+
"module" => "Elixir.Test.Foo.Bar"
134+
}
135+
},
136+
message:
137+
"undefined function bar/2 (expected Test.Foo to define such a function or for it to be imported, but none are available)",
138+
source: "Elixir",
139+
range: %GenLSP.Structures.Range{
140+
start: start,
141+
end: %{start | character: 9}
142+
}
143+
}
144+
145+
uri = "file:///home/owner/my_project/hello.ex"
146+
147+
assert [_, code_action] = UndefinedFunction.new(diagnostic, text, uri)
148+
assert %CodeAction{} = code_action
149+
assert [diagnostic] == code_action.diagnostics
150+
assert code_action.title == "Create private function bar/2"
151+
152+
edit_position = %Position{line: 5, character: 0}
153+
154+
assert %WorkspaceEdit{
155+
changes: %{
156+
^uri => [
157+
%TextEdit{
158+
new_text: """
159+
160+
defp bar(param1, param2) do
161+
162+
end
163+
""",
164+
range: %Range{start: ^edit_position, end: ^edit_position}
165+
}
166+
]
167+
}
168+
} = code_action.edit
169+
end
170+
end

0 commit comments

Comments
 (0)