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: add require code action #375

Merged
merged 5 commits into from
Feb 24, 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
4 changes: 4 additions & 0 deletions lib/next_ls/extensions/elixir_extension.ex
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,17 @@ 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"}

cond 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
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 @@ -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
Expand All @@ -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
Expand Down
121 changes: 121 additions & 0 deletions lib/next_ls/extensions/elixir_extension/code_action/require.ex
Original file line number Diff line number Diff line change
@@ -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
202 changes: 202 additions & 0 deletions test/next_ls/extensions/elixir_extension/code_action/require_test.exs
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We get the text as a list of lines, which for the unused variable code isn't a problem but it's better to be consistent if anyone copies simple tests from other files like me.

String.split(
"""
defmodule Test.Unused do
def hello() do
foo = 3
:world
end
end
""",
"\n"
)

start = %Position{character: 4, line: 3}

Expand Down
Loading
Loading