Skip to content

Commit

Permalink
feat: add require code action (#375)
Browse files Browse the repository at this point in the history
* feat: add require code action

This adds a require code action that adds `require Module` to your module whenever a
macro is used without requiring it beforehand.
It tries to insert the require after all the top level Elixir
macros(moduledoc, alias, require, import).

* Refactor indent clause

* Fix formatting

* Refactor module name with &Macro.to_string/1

* reword title

---------

Co-authored-by: Mitchell Hanberg <[email protected]>
  • Loading branch information
NJichev and mhanberg authored Feb 24, 2024
1 parent 5096334 commit 1d5ba4f
Show file tree
Hide file tree
Showing 6 changed files with 391 additions and 8 deletions.
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 =
String.split(
"""
defmodule Test.Unused do
def hello() do
foo = 3
:world
end
end
""",
"\n"
)

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

Expand Down
Loading

0 comments on commit 1d5ba4f

Please sign in to comment.