diff --git a/lib/next_ls/extensions/elixir_extension.ex b/lib/next_ls/extensions/elixir_extension.ex index 5d369f2e..a6c7166a 100644 --- a/lib/next_ls/extensions/elixir_extension.ex +++ b/lib/next_ls/extensions/elixir_extension.ex @@ -114,6 +114,7 @@ defmodule NextLS.ElixirExtension do def clamp(line), do: max(line, 0) @unused_variable ~r/variable\s\"[^\"]+\"\sis\sunused/ + @require_module ~r/you\smust\srequire/ defp metadata(diagnostic) do base = %{"namespace" => "elixir"} @@ -121,6 +122,9 @@ defmodule NextLS.ElixirExtension do is_binary(diagnostic.message) and diagnostic.message =~ @unused_variable -> Map.put(base, "type", "unused_variable") + is_binary(diagnostic.message) and diagnostic.message =~ @require_module -> + Map.put(base, "type", "require") + true -> base end diff --git a/lib/next_ls/extensions/elixir_extension/code_action.ex b/lib/next_ls/extensions/elixir_extension/code_action.ex index 1722b2b9..ea1310b7 100644 --- a/lib/next_ls/extensions/elixir_extension/code_action.ex +++ b/lib/next_ls/extensions/elixir_extension/code_action.ex @@ -4,6 +4,7 @@ defmodule NextLS.ElixirExtension.CodeAction do @behaviour NextLS.CodeActionable alias NextLS.CodeActionable.Data + alias NextLS.ElixirExtension.CodeAction.Require alias NextLS.ElixirExtension.CodeAction.UnusedVariable @impl true @@ -12,6 +13,9 @@ defmodule NextLS.ElixirExtension.CodeAction do %{"type" => "unused_variable"} -> UnusedVariable.new(data.diagnostic, data.document, data.uri) + %{"type" => "require"} -> + Require.new(data.diagnostic, data.document, data.uri) + _ -> [] end diff --git a/lib/next_ls/extensions/elixir_extension/code_action/require.ex b/lib/next_ls/extensions/elixir_extension/code_action/require.ex new file mode 100644 index 00000000..ea638847 --- /dev/null +++ b/lib/next_ls/extensions/elixir_extension/code_action/require.ex @@ -0,0 +1,121 @@ +defmodule NextLS.ElixirExtension.CodeAction.Require do + @moduledoc false + + alias GenLSP.Structures.CodeAction + alias GenLSP.Structures.Diagnostic + alias GenLSP.Structures.Position + alias GenLSP.Structures.Range + alias GenLSP.Structures.TextEdit + alias GenLSP.Structures.WorkspaceEdit + + @one_indentation_level " " + @spec new(diagnostic :: Diagnostic.t(), [text :: String.t()], uri :: String.t()) :: [CodeAction.t()] + def new(%Diagnostic{} = diagnostic, text, uri) do + range = diagnostic.range + + with {:ok, require_module} <- get_edit(diagnostic.message), + {:ok, ast} <- parse_ast(text), + {:ok, defm} <- nearest_defmodule(ast, range), + indentation <- get_indent(text, defm), + nearest <- find_nearest_node_for_require(defm), + range <- get_edit_range(nearest) do + [ + %CodeAction{ + title: "Add missing require for #{require_module}", + diagnostics: [diagnostic], + edit: %WorkspaceEdit{ + changes: %{ + uri => [ + %TextEdit{ + new_text: indentation <> "require #{require_module}\n", + range: range + } + ] + } + } + } + ] + else + _error -> + [] + end + end + + defp parse_ast(text) do + text + |> Enum.join("\n") + |> Spitfire.parse() + end + + defp nearest_defmodule(ast, range) do + defmodules = + ast + |> Macro.prewalker() + |> Enum.filter(fn + {:defmodule, _, _} -> true + _ -> false + end) + + if defmodules != [] do + defm = + Enum.min_by(defmodules, fn {_, ctx, _} -> + range.start.character - ctx[:line] + 1 + end) + + {:ok, defm} + else + {:error, "no defmodule definition"} + end + end + + @module_name ~r/require\s+([^\s]+)\s+before/ + defp get_edit(message) do + case Regex.run(@module_name, message) do + [_, module] -> {:ok, module} + _ -> {:error, "unable to find require"} + end + end + + # Context starts from 1 while LSP starts from 0 + # which works for us since we want to insert the require on the next line + defp get_edit_range(context) do + %Range{ + start: %Position{line: context[:line], character: 0}, + end: %Position{line: context[:line], character: 0} + } + end + + @indent ~r/^(\s*).*/ + defp get_indent(text, {_, defm_context, _}) do + line = defm_context[:line] - 1 + + indent = + text + |> Enum.at(line) + |> then(&Regex.run(@indent, &1)) + |> List.last() + + indent <> @one_indentation_level + end + + @top_level_macros [:import, :alias, :require] + defp find_nearest_node_for_require({:defmodule, context, _} = ast) do + top_level_macros = + ast + |> Macro.prewalker() + |> Enum.filter(fn + {:@, _, [{:moduledoc, _, _}]} -> true + {macro, _, _} when macro in @top_level_macros -> true + _ -> false + end) + + case top_level_macros do + [] -> + context + + _ -> + {_, context, _} = Enum.max_by(top_level_macros, fn {_, ctx, _} -> ctx[:line] end) + context + end + end +end diff --git a/test/next_ls/extensions/elixir_extension/code_action/require_test.exs b/test/next_ls/extensions/elixir_extension/code_action/require_test.exs new file mode 100644 index 00000000..39e5942b --- /dev/null +++ b/test/next_ls/extensions/elixir_extension/code_action/require_test.exs @@ -0,0 +1,202 @@ +defmodule NextLS.ElixirExtension.RequireTest do + use ExUnit.Case, async: true + + alias GenLSP.Structures.CodeAction + alias GenLSP.Structures.Position + alias GenLSP.Structures.Range + alias GenLSP.Structures.TextEdit + alias GenLSP.Structures.WorkspaceEdit + alias NextLS.ElixirExtension.CodeAction.Require + + test "adds require to module" do + text = + String.split( + """ + defmodule Test.Require do + def hello() do + Logger.info("foo") + end + end + """, + "\n" + ) + + start = %Position{character: 0, line: 1} + + diagnostic = %GenLSP.Structures.Diagnostic{ + data: %{"namespace" => "elixir", "type" => "require"}, + message: "you must require Logger before invoking the macro Logger.info/1", + source: "Elixir", + range: %GenLSP.Structures.Range{ + start: start, + end: %{start | character: 999} + } + } + + uri = "file:///home/owner/my_project/hello.ex" + + assert [code_action] = Require.new(diagnostic, text, uri) + assert is_struct(code_action, CodeAction) + assert [diagnostic] == code_action.diagnostics + assert code_action.title == "Add missing require for Logger" + + assert %WorkspaceEdit{ + changes: %{ + ^uri => [ + %TextEdit{ + new_text: " require Logger\n", + range: %Range{start: ^start, end: ^start} + } + ] + } + } = code_action.edit + end + + test "adds require after moduledoc" do + text = + String.split( + """ + defmodule Test.Require do + @moduledoc + def hello() do + Logger.info("foo") + end + end + """, + "\n" + ) + + start = %Position{character: 0, line: 2} + + diagnostic = %GenLSP.Structures.Diagnostic{ + data: %{"namespace" => "elixir", "type" => "require"}, + message: "you must require Logger before invoking the macro Logger.info/1", + source: "Elixir", + range: %GenLSP.Structures.Range{ + start: start, + end: %{start | character: 999} + } + } + + uri = "file:///home/owner/my_project/hello.ex" + + assert [code_action] = Require.new(diagnostic, text, uri) + assert is_struct(code_action, CodeAction) + assert [diagnostic] == code_action.diagnostics + assert code_action.title == "Add missing require for Logger" + + assert %WorkspaceEdit{ + changes: %{ + ^uri => [ + %TextEdit{ + new_text: " require Logger\n", + range: %Range{start: ^start, end: ^start} + } + ] + } + } = code_action.edit + end + + test "adds require after alias" do + text = + String.split( + """ + defmodule Test.Require do + @moduledoc + import Test.Foo + alias Test.Bar + def hello() do + Logger.info("foo") + end + end + """, + "\n" + ) + + start = %Position{character: 0, line: 4} + + diagnostic = %GenLSP.Structures.Diagnostic{ + data: %{"namespace" => "elixir", "type" => "require"}, + message: "you must require Logger before invoking the macro Logger.info/1", + source: "Elixir", + range: %GenLSP.Structures.Range{ + start: start, + end: %{start | character: 999} + } + } + + uri = "file:///home/owner/my_project/hello.ex" + + assert [code_action] = Require.new(diagnostic, text, uri) + assert is_struct(code_action, CodeAction) + assert [diagnostic] == code_action.diagnostics + assert code_action.title == "Add missing require for Logger" + + assert %WorkspaceEdit{ + changes: %{ + ^uri => [ + %TextEdit{ + new_text: " require Logger\n", + range: %Range{start: ^start, end: ^start} + } + ] + } + } = code_action.edit + end + + test "figures out the correct module" do + text = + String.split( + """ + defmodule Test do + defmodule Foo do + def hello() do + IO.inspect("foo") + end + end + + defmodule Require do + @moduledoc + import Test.Foo + alias Test.Bar + + def hello() do + Logger.info("foo") + end + end + end + """, + "\n" + ) + + start = %Position{character: 0, line: 11} + + diagnostic = %GenLSP.Structures.Diagnostic{ + data: %{"namespace" => "elixir", "type" => "require"}, + message: "you must require Logger before invoking the macro Logger.info/1", + source: "Elixir", + range: %GenLSP.Structures.Range{ + start: start, + end: %{start | character: 999} + } + } + + uri = "file:///home/owner/my_project/hello.ex" + + assert [code_action] = Require.new(diagnostic, text, uri) + assert is_struct(code_action, CodeAction) + assert [diagnostic] == code_action.diagnostics + assert code_action.title == "Add missing require for Logger" + + assert %WorkspaceEdit{ + changes: %{ + ^uri => [ + %TextEdit{ + new_text: " require Logger\n", + range: %Range{start: ^start, end: ^start} + } + ] + } + } = code_action.edit + end +end diff --git a/test/next_ls/extensions/elixir_extension/code_action/unused_variable_test.exs b/test/next_ls/extensions/elixir_extension/code_action/unused_variable_test.exs index 070e50d7..9252fc5c 100644 --- a/test/next_ls/extensions/elixir_extension/code_action/unused_variable_test.exs +++ b/test/next_ls/extensions/elixir_extension/code_action/unused_variable_test.exs @@ -9,14 +9,18 @@ defmodule NextLS.ElixirExtension.UnusedVariableTest do alias NextLS.ElixirExtension.CodeAction.UnusedVariable test "adds an underscore to unused variables" do - text = """ - defmodule Test.Unused do - def hello() do - foo = 3 - :world - end - end - """ + text = + String.split( + """ + defmodule Test.Unused do + def hello() do + foo = 3 + :world + end + end + """, + "\n" + ) start = %Position{character: 4, line: 3} diff --git a/test/next_ls/extensions/elixir_extension/code_action_test.exs b/test/next_ls/extensions/elixir_extension/code_action_test.exs index 945e0769..09fd4846 100644 --- a/test/next_ls/extensions/elixir_extension/code_action_test.exs +++ b/test/next_ls/extensions/elixir_extension/code_action_test.exs @@ -21,6 +21,10 @@ defmodule NextLS.Extensions.ElixirExtension.CodeActionTest do foo = :bar :world end + + def world() do + Logger.info("no require") + end end """ @@ -74,4 +78,48 @@ defmodule NextLS.Extensions.ElixirExtension.CodeActionTest do }, 500 end + + test "can send more than one code action", %{client: client, foo_path: foo} do + foo_uri = uri(foo) + id = 1 + + request client, %{ + method: "textDocument/codeAction", + id: id, + jsonrpc: "2.0", + params: %{ + context: %{ + "diagnostics" => [ + %{ + "data" => %{"namespace" => "elixir", "type" => "unused_variable"}, + "message" => + "variable \"foo\" is unused (if the variable is not meant to be used, prefix it with an underscore)", + "range" => %{"end" => %{"character" => 999, "line" => 2}, "start" => %{"character" => 4, "line" => 2}}, + "severity" => 2, + "source" => "Elixir" + }, + %{ + "data" => %{"namespace" => "elixir", "type" => "require"}, + "message" => "you must require Logger before invoking the macro Logger.info/1", + "range" => %{"end" => %{"character" => 999, "line" => 7}, "start" => %{"character" => 0, "line" => 7}}, + "severity" => 2, + "source" => "Elixir" + } + ] + }, + range: %{start: %{line: 2, character: 0}, end: %{line: 7, character: 999}}, + textDocument: %{uri: foo_uri} + } + } + + assert_receive %{ + "jsonrpc" => "2.0", + "id" => 1, + "result" => [ + %{"edit" => %{"changes" => %{^foo_uri => [%{"newText" => "_"}]}}}, + %{"edit" => %{"changes" => %{^foo_uri => [%{"newText" => " require Logger\n"}]}}} + ] + }, + 500 + end end