Skip to content

Commit 8b9a57c

Browse files
authored
feat: unused variable code action (#349)
1 parent 6f4c522 commit 8b9a57c

File tree

11 files changed

+272
-3
lines changed

11 files changed

+272
-3
lines changed

Diff for: lib/next_ls.ex

+30
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ defmodule NextLS do
44

55
import NextLS.DB.Query
66

7+
alias GenLSP.Enumerations.CodeActionKind
78
alias GenLSP.Enumerations.ErrorCodes
89
alias GenLSP.Enumerations.TextDocumentSyncKind
910
alias GenLSP.ErrorResponse
@@ -16,13 +17,18 @@ defmodule NextLS do
1617
alias GenLSP.Notifications.WorkspaceDidChangeWorkspaceFolders
1718
alias GenLSP.Requests.Initialize
1819
alias GenLSP.Requests.Shutdown
20+
alias GenLSP.Requests.TextDocumentCodeAction
1921
alias GenLSP.Requests.TextDocumentCompletion
2022
alias GenLSP.Requests.TextDocumentDefinition
2123
alias GenLSP.Requests.TextDocumentDocumentSymbol
2224
alias GenLSP.Requests.TextDocumentFormatting
2325
alias GenLSP.Requests.TextDocumentHover
2426
alias GenLSP.Requests.TextDocumentReferences
2527
alias GenLSP.Requests.WorkspaceSymbol
28+
alias GenLSP.Structures.CodeActionContext
29+
alias GenLSP.Structures.CodeActionOptions
30+
alias GenLSP.Structures.CodeActionParams
31+
alias GenLSP.Structures.Diagnostic
2632
alias GenLSP.Structures.DidChangeWatchedFilesParams
2733
alias GenLSP.Structures.DidChangeWorkspaceFoldersParams
2834
alias GenLSP.Structures.DidOpenTextDocumentParams
@@ -34,6 +40,7 @@ defmodule NextLS do
3440
alias GenLSP.Structures.SaveOptions
3541
alias GenLSP.Structures.ServerCapabilities
3642
alias GenLSP.Structures.SymbolInformation
43+
alias GenLSP.Structures.TextDocumentIdentifier
3744
alias GenLSP.Structures.TextDocumentItem
3845
alias GenLSP.Structures.TextDocumentSyncOptions
3946
alias GenLSP.Structures.TextEdit
@@ -118,6 +125,9 @@ defmodule NextLS do
118125
save: %SaveOptions{include_text: true},
119126
change: TextDocumentSyncKind.full()
120127
},
128+
code_action_provider: %CodeActionOptions{
129+
code_action_kinds: [CodeActionKind.quick_fix()]
130+
},
121131
completion_provider:
122132
if init_opts.experimental.completions.enable do
123133
%GenLSP.Structures.CompletionOptions{
@@ -149,6 +159,26 @@ defmodule NextLS do
149159
)}
150160
end
151161

162+
def handle_request(
163+
%TextDocumentCodeAction{
164+
params: %CodeActionParams{
165+
context: %CodeActionContext{diagnostics: diagnostics},
166+
text_document: %TextDocumentIdentifier{uri: uri}
167+
}
168+
},
169+
lsp
170+
) do
171+
code_actions =
172+
for %Diagnostic{} = diagnostic <- diagnostics,
173+
data = %NextLS.CodeActionable.Data{diagnostic: diagnostic, uri: uri, document: lsp.assigns.documents[uri]},
174+
namespace = diagnostic.data["namespace"],
175+
action <- NextLS.CodeActionable.from(namespace, data) do
176+
action
177+
end
178+
179+
{:reply, code_actions, lsp}
180+
end
181+
152182
def handle_request(%TextDocumentDefinition{params: %{text_document: %{uri: uri}, position: position}}, lsp) do
153183
result =
154184
dispatch(lsp.assigns.registry, :databases, fn entries ->

Diff for: lib/next_ls/code_actionable.ex

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
defmodule NextLS.CodeActionable do
2+
@moduledoc false
3+
# A diagnostic can produce 1 or more code actions hence we return a list
4+
5+
alias GenLSP.Structures.CodeAction
6+
alias GenLSP.Structures.Diagnostic
7+
8+
defmodule Data do
9+
@moduledoc false
10+
defstruct [:diagnostic, :uri, :document]
11+
12+
@type t :: %__MODULE__{
13+
diagnostic: Diagnostic.t(),
14+
uri: String.t(),
15+
document: String.t()
16+
}
17+
end
18+
19+
@callback from(diagnostic :: Data.t()) :: [CodeAction.t()]
20+
21+
# TODO: Add support for third party extensions
22+
def from("elixir", diagnostic_data) do
23+
NextLS.ElixirExtension.CodeAction.from(diagnostic_data)
24+
end
25+
26+
def from("credo", diagnostic_data) do
27+
NextLS.CredoExtension.CodeAction.from(diagnostic_data)
28+
end
29+
end

Diff for: lib/next_ls/extensions/credo_extension.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ defmodule NextLS.CredoExtension do
123123
}
124124
},
125125
severity: category_to_severity(issue.category),
126-
data: %{check: issue.check, file: issue.filename},
126+
data: %{check: issue.check, file: issue.filename, namespace: :credo},
127127
source: "credo",
128128
code: Macro.to_string(issue.check),
129129
code_description: %CodeDescription{
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
defmodule NextLS.CredoExtension.CodeAction do
2+
@moduledoc false
3+
4+
@behaviour NextLS.CodeActionable
5+
6+
alias NextLS.CodeActionable.Data
7+
8+
@impl true
9+
def from(%Data{} = _data), do: []
10+
end

Diff for: lib/next_ls/extensions/elixir_extension.ex

+15-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ defmodule NextLS.ElixirExtension do
4444
severity: severity(d.severity),
4545
message: IO.iodata_to_binary(d.message),
4646
source: d.compiler_name,
47-
range: range(d.position, Map.get(d, :span))
47+
range: range(d.position, Map.get(d, :span)),
48+
data: metadata(d)
4849
})
4950
end
5051

@@ -111,4 +112,17 @@ defmodule NextLS.ElixirExtension do
111112
end
112113

113114
def clamp(line), do: max(line, 0)
115+
116+
@unused_variable ~r/variable\s\"[^\"]+\"\sis\sunused/
117+
defp metadata(diagnostic) do
118+
base = %{"namespace" => "elixir"}
119+
120+
cond do
121+
is_binary(diagnostic.message) and diagnostic.message =~ @unused_variable ->
122+
Map.put(base, "type", "unused_variable")
123+
124+
true ->
125+
base
126+
end
127+
end
114128
end
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
defmodule NextLS.ElixirExtension.CodeAction do
2+
@moduledoc false
3+
4+
@behaviour NextLS.CodeActionable
5+
6+
alias NextLS.CodeActionable.Data
7+
alias NextLS.ElixirExtension.CodeAction.UnusedVariable
8+
9+
@impl true
10+
def from(%Data{} = data) do
11+
case data.diagnostic.data do
12+
%{"type" => "unused_variable"} ->
13+
UnusedVariable.new(data.diagnostic, data.document, data.uri)
14+
15+
_ ->
16+
[]
17+
end
18+
end
19+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
defmodule NextLS.ElixirExtension.CodeAction.UnusedVariable 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+
10+
def new(diagnostic, _text, uri) do
11+
%Diagnostic{range: %{start: start}} = diagnostic
12+
13+
[
14+
%CodeAction{
15+
title: "Underscore unused variable",
16+
diagnostics: [diagnostic],
17+
edit: %WorkspaceEdit{
18+
changes: %{
19+
uri => [
20+
%TextEdit{
21+
new_text: "_",
22+
range: %Range{
23+
start: start,
24+
end: start
25+
}
26+
}
27+
]
28+
}
29+
}
30+
}
31+
]
32+
end
33+
end

Diff for: test/next_ls/diagnostics_test.exs

+2-1
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ defmodule NextLS.DiagnosticsTest do
100100
"range" => %{
101101
"start" => %{"line" => 3, "character" => ^char},
102102
"end" => %{"line" => 3, "character" => 14}
103-
}
103+
},
104+
"data" => %{"type" => "unused_variable", "namespace" => "elixir"}
104105
}
105106
]
106107
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
defmodule NextLS.ElixirExtension.UnusedVariableTest 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.UnusedVariable
10+
11+
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+
"""
20+
21+
start = %Position{character: 4, line: 3}
22+
23+
diagnostic = %GenLSP.Structures.Diagnostic{
24+
data: %{"namespace" => "elixir", "type" => "unused_variable"},
25+
message: "variable \"foo\" is unused (if the variable is not meant to be used, prefix it with an underscore)",
26+
source: "Elixir",
27+
range: %GenLSP.Structures.Range{
28+
start: start,
29+
end: %{start | character: 999}
30+
}
31+
}
32+
33+
uri = "file:///home/owner/my_project/hello.ex"
34+
35+
assert [code_action] = UnusedVariable.new(diagnostic, text, uri)
36+
assert is_struct(code_action, CodeAction)
37+
assert [diagnostic] == code_action.diagnostics
38+
39+
# We insert a single underscore character at the start position of the unused variable
40+
# Hence the start and end positions are matching the original start position in the diagnostic
41+
assert %WorkspaceEdit{
42+
changes: %{
43+
^uri => [
44+
%TextEdit{
45+
new_text: "_",
46+
range: %Range{start: ^start, end: ^start}
47+
}
48+
]
49+
}
50+
} = code_action.edit
51+
end
52+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
defmodule NextLS.Extensions.ElixirExtension.CodeActionTest 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 MyProj.Foo do
20+
def hello() do
21+
foo = :bar
22+
:world
23+
end
24+
end
25+
"""
26+
27+
File.write!(foo_path, foo)
28+
29+
[foo: foo, foo_path: foo_path]
30+
end
31+
32+
setup :with_lsp
33+
34+
setup context do
35+
assert :ok == notify(context.client, %{method: "initialized", jsonrpc: "2.0", params: %{}})
36+
assert_is_ready(context, "my_proj")
37+
assert_compiled(context, "my_proj")
38+
assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}}
39+
40+
did_open(context.client, context.foo_path, context.foo)
41+
context
42+
end
43+
44+
test "sends back a list of code actions", %{client: client, foo_path: foo} do
45+
foo_uri = uri(foo)
46+
id = 1
47+
48+
request client, %{
49+
method: "textDocument/codeAction",
50+
id: id,
51+
jsonrpc: "2.0",
52+
params: %{
53+
context: %{
54+
"diagnostics" => [
55+
%{
56+
"data" => %{"namespace" => "elixir", "type" => "unused_variable"},
57+
"message" =>
58+
"variable \"foo\" is unused (if the variable is not meant to be used, prefix it with an underscore)",
59+
"range" => %{"end" => %{"character" => 999, "line" => 2}, "start" => %{"character" => 4, "line" => 2}},
60+
"severity" => 2,
61+
"source" => "Elixir"
62+
}
63+
]
64+
},
65+
range: %{start: %{line: 2, character: 4}, end: %{line: 2, character: 999}},
66+
textDocument: %{uri: foo_uri}
67+
}
68+
}
69+
70+
assert_receive %{
71+
"jsonrpc" => "2.0",
72+
"id" => 1,
73+
"result" => [%{"edit" => %{"changes" => %{^foo_uri => [%{"newText" => "_"}]}}}]
74+
},
75+
500
76+
end
77+
end

Diff for: test/next_ls/extensions/elixir_extension_test.exs

+4
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ defmodule NextLS.ElixirExtensionTest do
6565
assert %{
6666
with_iodata.file => [
6767
%GenLSP.Structures.Diagnostic{
68+
data: %{"namespace" => "elixir"},
6869
severity: 2,
6970
message:
7071
"ElixirExtension.foo/0" <>
@@ -84,6 +85,7 @@ defmodule NextLS.ElixirExtensionTest do
8485
],
8586
only_line.file => [
8687
%GenLSP.Structures.Diagnostic{
88+
data: %{"namespace" => "elixir"},
8789
severity: 2,
8890
message: "kind of bad",
8991
source: "Elixir",
@@ -101,6 +103,7 @@ defmodule NextLS.ElixirExtensionTest do
101103
],
102104
line_and_col.file => [
103105
%GenLSP.Structures.Diagnostic{
106+
data: %{"namespace" => "elixir"},
104107
severity: 1,
105108
message: "nothing works",
106109
source: "Elixir",
@@ -118,6 +121,7 @@ defmodule NextLS.ElixirExtensionTest do
118121
],
119122
start_and_end.file => [
120123
%GenLSP.Structures.Diagnostic{
124+
data: %{"namespace" => "elixir"},
121125
severity: 4,
122126
message: "here's a hint",
123127
source: "Elixir",

0 commit comments

Comments
 (0)