Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: undefined function code action #441

Merged
merged 3 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions lib/next_ls/extensions/elixir_extension.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 (?<name>.*)\/(?<arity>\d) \(expected (?<module>.*) to define such a function or for it to be imported, but none are available\)/
defp metadata(diagnostic) do
base = %{"namespace" => "elixir"}

Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/next_ls/extensions/elixir_extension/code_action.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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}
}
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading