Skip to content

Commit

Permalink
feat: undefined function code action (#441)
Browse files Browse the repository at this point in the history
When you have an undefined local function, this code action allows you
to create a private function stub for the function.
  • Loading branch information
mhanberg authored Apr 25, 2024
1 parent 9c2ff68 commit d03c1ad
Show file tree
Hide file tree
Showing 5 changed files with 261 additions and 10 deletions.
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

0 comments on commit d03c1ad

Please sign in to comment.