diff --git a/apps/language_server/lib/language_server/protocol.ex b/apps/language_server/lib/language_server/protocol.ex index faf5af976..0845bfa4b 100644 --- a/apps/language_server/lib/language_server/protocol.ex +++ b/apps/language_server/lib/language_server/protocol.ex @@ -200,6 +200,17 @@ defmodule ElixirLS.LanguageServer.Protocol do end end + defmacro code_action_req(id, uri, diagnostics) do + quote do + request(unquote(id), "textDocument/codeAction", %{ + "context" => %{"diagnostics" => unquote(diagnostics)}, + "textDocument" => %{ + "uri" => unquote(uri) + } + }) + end + end + # Other utilities defmacro range(start_line, start_character, end_line, end_character) do diff --git a/apps/language_server/lib/language_server/providers/code_action.ex b/apps/language_server/lib/language_server/providers/code_action.ex new file mode 100644 index 000000000..9be0474a1 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/code_action.ex @@ -0,0 +1,61 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeAction do + use ElixirLS.LanguageServer.Protocol + + def code_actions(uri, diagnostics) do + actions = + diagnostics + |> Enum.map(fn diagnostic -> actions(uri, diagnostic) end) + |> List.flatten() + + {:ok, actions} + end + + defp actions(uri, %{"message" => message, "range" => range} = diagnostic) do + [ + {~r/variable "(.*)" is unused/, &prefix_with_underscore/2}, + {~r/variable "(.*)" is unused/, &remove_variable/2} + ] + |> Enum.filter(fn {r, _fun} -> String.match?(message, r) end) + |> Enum.map(fn {_r, fun} -> fun.(uri, diagnostic) end) + end + + defp prefix_with_underscore(uri, %{"message" => message, "range" => range} = diagnostic) do + %{ + "title" => "Add '_' to unused variable", + "kind" => "quickfix", + "edit" => %{ + "changes" => %{ + uri => [ + %{ + "newText" => "_", + "range" => + range( + range["start"]["line"], + range["start"]["character"], + range["start"]["line"], + range["start"]["character"] + ) + } + ] + } + } + } + end + + defp remove_variable(uri, %{"range" => range} = diagnostic) do + %{ + "title" => "Remove unused variable", + "kind" => "quickfix", + "edit" => %{ + "changes" => %{ + uri => [ + %{ + "newText" => "", + "range" => range + } + ] + } + } + } + end +end diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index b66a2f4c7..75442018b 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -31,7 +31,8 @@ defmodule ElixirLS.LanguageServer.Server do OnTypeFormatting, CodeLens, ExecuteCommand, - FoldingRange + FoldingRange, + CodeAction } alias ElixirLS.Utils.Launch @@ -325,7 +326,9 @@ defmodule ElixirLS.LanguageServer.Server do # close notification send before JsonRpc.log_message( :warning, - "Received textDocument/didOpen for file that is already open. Received uri: #{inspect(uri)}" + "Received textDocument/didOpen for file that is already open. Received uri: #{ + inspect(uri) + }" ) state @@ -769,6 +772,10 @@ defmodule ElixirLS.LanguageServer.Server do end end + defp handle_request(code_action_req(id, uri, diagnostics) = req, state = %__MODULE__{}) do + {:async, fn -> CodeAction.code_actions(uri, diagnostics) end, state} + end + defp handle_request(%{"method" => "$/" <> _}, state = %__MODULE__{}) do # "$/" requests that the server doesn't support must return method_not_found {:error, :method_not_found, nil, state} @@ -820,7 +827,8 @@ defmodule ElixirLS.LanguageServer.Server do "workspace" => %{ "workspaceFolders" => %{"supported" => false, "changeNotifications" => false} }, - "foldingRangeProvider" => true + "foldingRangeProvider" => true, + "codeActionProvider" => true } end diff --git a/apps/language_server/test/server_test.exs b/apps/language_server/test/server_test.exs index c9faf3b0e..8bcaaf80e 100644 --- a/apps/language_server/test/server_test.exs +++ b/apps/language_server/test/server_test.exs @@ -1482,6 +1482,70 @@ defmodule ElixirLS.LanguageServer.ServerTest do end end + describe "textDocument/codeAction" do + test "return code actions on unused variables", %{server: server} do + uri = "file:///file.ex" + fake_initialize(server) + + Server.receive_packet(server, did_open(uri, "elixir", 1, "")) + + Server.receive_packet( + server, + code_action_req(1, uri, [ + %{ + "message" => + "variable \"foo\" is unused (if the variable is not meant to be used, prefix it with an underscore)", + "range" => %{ + "end" => %{"character" => 13, "line" => 19}, + "start" => %{"character" => 4, "line" => 19} + }, + "severity" => 1, + "source" => "Elixir" + } + ]) + ) + + resp = assert_receive(%{"id" => 1}, 5000) + + assert response(1, [ + %{ + "edit" => %{ + "changes" => %{ + "file:///file.ex" => [ + %{ + "newText" => "_", + "range" => %{ + "end" => %{"character" => 4, "line" => 19}, + "start" => %{"character" => 4, "line" => 19} + } + } + ] + } + }, + "kind" => "quickfix", + "title" => "Add '_' to unused variable" + }, + %{ + "edit" => %{ + "changes" => %{ + "file:///file.ex" => [ + %{ + "newText" => "", + "range" => %{ + "end" => %{"character" => 13, "line" => 19}, + "start" => %{"character" => 4, "line" => 19} + } + } + ] + } + }, + "kind" => "quickfix", + "title" => "Remove unused variable" + } + ]) == resp + end + end + defp with_new_server(func) do server = start_supervised!({Server, nil}) packet_capture = start_supervised!({PacketCapture, self()})