diff --git a/lib/next_ls/extensions/elixir_extension.ex b/lib/next_ls/extensions/elixir_extension.ex index a6c7166a..0d161e3e 100644 --- a/lib/next_ls/extensions/elixir_extension.ex +++ b/lib/next_ls/extensions/elixir_extension.ex @@ -32,14 +32,6 @@ defmodule NextLS.ElixirExtension do DiagnosticCache.clear(state.cache, :elixir) for d <- diagnostics do - # TODO: some compiler diagnostics only have the line number - # but we want to only highlight the source code, so we - # need to read the text of the file (either from the lsp cache - # if the source code is "open", or read from disk) and calculate the - # column of the first non-whitespace character. - # - # it is not clear to me whether the LSP process or the extension should - # be responsible for this. The open documents live in the LSP process DiagnosticCache.put(state.cache, :elixir, d.file, %GenLSP.Structures.Diagnostic{ severity: severity(d.severity), message: IO.iodata_to_binary(d.message), @@ -115,6 +107,7 @@ defmodule NextLS.ElixirExtension do @unused_variable ~r/variable\s\"[^\"]+\"\sis\sunused/ @require_module ~r/you\smust\srequire/ + @undefined_local_function ~r/undefined function (?.*)\/(?\d) \(expected (?.*) to define such a function or for it to be imported, but none are available\)/ defp metadata(diagnostic) do base = %{"namespace" => "elixir"} @@ -125,6 +118,10 @@ defmodule NextLS.ElixirExtension do is_binary(diagnostic.message) and diagnostic.message =~ @require_module -> Map.put(base, "type", "require") + is_binary(diagnostic.message) and diagnostic.message =~ @undefined_local_function -> + info = Regex.named_captures(@undefined_local_function, diagnostic.message) + base |> Map.put("type", "undefined-function") |> Map.put("info", info) + 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 ea1310b7..d7b92a67 100644 --- a/lib/next_ls/extensions/elixir_extension/code_action.ex +++ b/lib/next_ls/extensions/elixir_extension/code_action.ex @@ -5,6 +5,7 @@ defmodule NextLS.ElixirExtension.CodeAction do alias NextLS.CodeActionable.Data alias NextLS.ElixirExtension.CodeAction.Require + alias NextLS.ElixirExtension.CodeAction.UndefinedFunction alias NextLS.ElixirExtension.CodeAction.UnusedVariable @impl true @@ -16,6 +17,9 @@ defmodule NextLS.ElixirExtension.CodeAction do %{"type" => "require"} -> Require.new(data.diagnostic, data.document, data.uri) + %{"type" => "undefined-function"} -> + UndefinedFunction.new(data.diagnostic, data.document, data.uri) + _ -> [] end diff --git a/lib/next_ls/extensions/elixir_extension/code_action/undefined_function.ex b/lib/next_ls/extensions/elixir_extension/code_action/undefined_function.ex new file mode 100644 index 00000000..738386ba --- /dev/null +++ b/lib/next_ls/extensions/elixir_extension/code_action/undefined_function.ex @@ -0,0 +1,78 @@ +defmodule NextLS.ElixirExtension.CodeAction.UndefinedFunction do + @moduledoc false + + alias GenLSP.Structures.CodeAction + alias GenLSP.Structures.Diagnostic + alias GenLSP.Structures.Range + alias GenLSP.Structures.TextEdit + alias GenLSP.Structures.WorkspaceEdit + alias NextLS.ASTHelpers + + def new(diagnostic, text, uri) do + %Diagnostic{range: range, data: %{"info" => %{"name" => name, "arity" => arity}}} = diagnostic + + with {:ok, ast} <- + text + |> Enum.join("\n") + |> Spitfire.parse(literal_encoder: &{:ok, {:__block__, &2, [&1]}}), + {:ok, {:defmodule, meta, _} = defm} <- ASTHelpers.get_surrounding_module(ast, range.start) do + indentation = get_indent(text, defm) + + position = %GenLSP.Structures.Position{ + line: meta[:end][:line] - 1, + character: 0 + } + + params = if arity == "0", do: "", else: Enum.map_join(1..String.to_integer(arity), ", ", fn i -> "param#{i}" end) + + action = fn title, new_text -> + %CodeAction{ + title: title, + diagnostics: [diagnostic], + edit: %WorkspaceEdit{ + changes: %{ + uri => [ + %TextEdit{ + new_text: new_text, + range: %Range{ + start: position, + end: position + } + } + ] + } + } + } + end + + [ + action.("Create public function #{name}/#{arity}", """ + + #{indentation}def #{name}(#{params}) do + + #{indentation}end + """), + action.("Create private function #{name}/#{arity}", """ + + #{indentation}defp #{name}(#{params}) do + + #{indentation}end + """) + ] + end + end + + @one_indentation_level " " + @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 +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 index 39e5942b..e960119c 100644 --- a/test/next_ls/extensions/elixir_extension/code_action/require_test.exs +++ b/test/next_ls/extensions/elixir_extension/code_action/require_test.exs @@ -21,7 +21,7 @@ defmodule NextLS.ElixirExtension.RequireTest do "\n" ) - start = %Position{character: 0, line: 1} + start = %Position{character: 11, line: 2} diagnostic = %GenLSP.Structures.Diagnostic{ data: %{"namespace" => "elixir", "type" => "require"}, @@ -40,12 +40,14 @@ defmodule NextLS.ElixirExtension.RequireTest do assert [diagnostic] == code_action.diagnostics assert code_action.title == "Add missing require for Logger" + edit_position = %GenLSP.Structures.Position{line: 1, character: 0} + assert %WorkspaceEdit{ changes: %{ ^uri => [ %TextEdit{ new_text: " require Logger\n", - range: %Range{start: ^start, end: ^start} + range: %Range{start: ^edit_position, end: ^edit_position} } ] } diff --git a/test/next_ls/extensions/elixir_extension/code_action/undefined_function_test.exs b/test/next_ls/extensions/elixir_extension/code_action/undefined_function_test.exs new file mode 100644 index 00000000..4dc3180e --- /dev/null +++ b/test/next_ls/extensions/elixir_extension/code_action/undefined_function_test.exs @@ -0,0 +1,170 @@ +defmodule NextLS.ElixirExtension.UndefinedFunctionTest 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.UndefinedFunction + + test "in outer module creates new private function inside current module" do + text = + String.split( + """ + defmodule Test.Foo do + defmodule Bar do + def run() do + :ok + end + end + + def hello() do + bar(1, 2) + end + + defmodule Baz do + def run() do + :error + end + end + end + """, + "\n" + ) + + start = %Position{character: 4, line: 8} + + diagnostic = %GenLSP.Structures.Diagnostic{ + data: %{ + "namespace" => "elixir", + "type" => "undefined-function", + "info" => %{ + "name" => "bar", + "arity" => "2", + "module" => "Elixir.Test.Foo" + } + }, + message: + "undefined function bar/2 (expected Test.Foo to define such a function or for it to be imported, but none are available)", + source: "Elixir", + range: %GenLSP.Structures.Range{ + start: start, + end: %{start | character: 6} + } + } + + uri = "file:///home/owner/my_project/hello.ex" + + assert [public, private] = UndefinedFunction.new(diagnostic, text, uri) + assert [diagnostic] == public.diagnostics + assert public.title == "Create public function bar/2" + + edit_position = %Position{line: 16, character: 0} + + assert %WorkspaceEdit{ + changes: %{ + ^uri => [ + %TextEdit{ + new_text: """ + + def bar(param1, param2) do + + end + """, + range: %Range{start: ^edit_position, end: ^edit_position} + } + ] + } + } = public.edit + + assert [diagnostic] == private.diagnostics + assert private.title == "Create private function bar/2" + + edit_position = %Position{line: 16, character: 0} + + assert %WorkspaceEdit{ + changes: %{ + ^uri => [ + %TextEdit{ + new_text: """ + + defp bar(param1, param2) do + + end + """, + range: %Range{start: ^edit_position, end: ^edit_position} + } + ] + } + } = private.edit + end + + test "in inner module creates new private function inside current module" do + text = + String.split( + """ + defmodule Test.Foo do + defmodule Bar do + def run() do + bar(1, 2) + end + end + + defmodule Baz do + def run() do + :error + end + end + end + """, + "\n" + ) + + start = %Position{character: 6, line: 3} + + diagnostic = %GenLSP.Structures.Diagnostic{ + data: %{ + "namespace" => "elixir", + "type" => "undefined-function", + "info" => %{ + "name" => "bar", + "arity" => "2", + "module" => "Elixir.Test.Foo.Bar" + } + }, + message: + "undefined function bar/2 (expected Test.Foo to define such a function or for it to be imported, but none are available)", + source: "Elixir", + range: %GenLSP.Structures.Range{ + start: start, + end: %{start | character: 9} + } + } + + uri = "file:///home/owner/my_project/hello.ex" + + assert [_, code_action] = UndefinedFunction.new(diagnostic, text, uri) + assert %CodeAction{} = code_action + assert [diagnostic] == code_action.diagnostics + assert code_action.title == "Create private function bar/2" + + edit_position = %Position{line: 5, character: 0} + + assert %WorkspaceEdit{ + changes: %{ + ^uri => [ + %TextEdit{ + new_text: """ + + defp bar(param1, param2) do + + end + """, + range: %Range{start: ^edit_position, end: ^edit_position} + } + ] + } + } = code_action.edit + end +end