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 alias refactor workspace command #386

Merged
merged 22 commits into from
Apr 19, 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
18 changes: 16 additions & 2 deletions lib/next_ls.ex
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ defmodule NextLS do
execute_command_provider: %GenLSP.Structures.ExecuteCommandOptions{
commands: [
"to-pipe",
"from-pipe"
"from-pipe",
"alias-refactor"
]
},
hover_provider: true,
Expand Down Expand Up @@ -769,6 +770,19 @@ defmodule NextLS do
position: position
})

"alias-refactor" ->
[arguments] = params.arguments

uri = arguments["uri"]
position = arguments["position"]
text = lsp.assigns.documents[uri]

NextLS.Commands.Alias.run(%{
uri: uri,
text: text,
position: position
})

_ ->
NextLS.Logger.show_message(
lsp.logger,
Expand All @@ -783,7 +797,7 @@ defmodule NextLS do
%WorkspaceEdit{} = edit ->
GenLSP.request(lsp, %WorkspaceApplyEdit{
id: System.unique_integer([:positive]),
params: %ApplyWorkspaceEditParams{label: "Pipe", edit: edit}
params: %ApplyWorkspaceEditParams{label: NextLS.Commands.label(command), edit: edit}
})

_reply ->
Expand Down
15 changes: 15 additions & 0 deletions lib/next_ls/commands.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule NextLS.Commands do
@moduledoc false

@labels %{
"from-pipe" => "Inlined pipe",
"to-pipe" => "Extracted to a pipe",
"alias-refactor" => "Refactored with an alias"
}
@doc "Creates a label for the workspace apply struct from the command name"
def label(command) when is_map_key(@labels, command), do: @labels[command]

def label(command) do
raise ArgumentError, "command #{inspect(command)} not supported"
end
end
140 changes: 140 additions & 0 deletions lib/next_ls/commands/alias.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
defmodule NextLS.Commands.Alias do
@moduledoc """
Refactors a module with fully qualified calls to an alias.
The cursor position should be under the module name that you wish to alias.
"""
import Schematic

alias GenLSP.Enumerations.ErrorCodes
alias GenLSP.Structures.Position
alias GenLSP.Structures.Range
alias GenLSP.Structures.TextEdit
alias GenLSP.Structures.WorkspaceEdit
alias NextLS.ASTHelpers
alias NextLS.EditHelpers
alias Sourceror.Zipper, as: Z

@line_length 121

defp opts do
map(%{
position: Position.schematic(),
uri: str(),
text: list(str())
})
end

def run(opts) do
with {:ok, %{text: text, uri: uri, position: position}} <- unify(opts(), Map.new(opts)),
{:ok, ast, comments} = parse(text),
{:ok, defm} <- ASTHelpers.get_surrounding_module(ast, position),
{:ok, {:__aliases__, _, modules}} <- get_node(ast, position) do
range = make_range(defm)
indent = EditHelpers.get_indent(text, range.start.line)
aliased = get_aliased(defm, modules)

comments =
Enum.filter(comments, fn comment ->
comment.line > range.start.line && comment.line <= range.end.line
end)

to_algebra_opts = [comments: comments]
doc = Code.quoted_to_algebra(aliased, to_algebra_opts)
formatted = doc |> Inspect.Algebra.format(@line_length) |> IO.iodata_to_binary()

%WorkspaceEdit{
changes: %{
uri => [
%TextEdit{
new_text:
EditHelpers.add_indent_to_edit(
formatted,
indent
),
range: range
}
]
}
}
else
{:error, message} ->
%GenLSP.ErrorResponse{code: ErrorCodes.parse_error(), message: inspect(message)}
end
end

defp parse(lines) do
lines
|> Enum.join("\n")
|> Spitfire.parse_with_comments(literal_encoder: &{:ok, {:__block__, &2, [&1]}})
|> case do
{:error, ast, comments, _errors} ->
{:ok, ast, comments}

other ->
other
end
end

defp make_range(original_ast) do
range = Sourceror.get_range(original_ast)

%Range{
start: %Position{line: range.start[:line] - 1, character: range.start[:column] - 1},
end: %Position{line: range.end[:line] - 1, character: range.end[:column] - 1}
}
end

def get_node(ast, pos) do
pos = [line: pos.line + 1, column: pos.character + 1]

result =
ast
|> Z.zip()
|> Z.traverse(nil, fn tree, acc ->
node = Z.node(tree)
range = Sourceror.get_range(node)

if not is_nil(range) and
match?({:__aliases__, _context, _modules}, node) &&
Sourceror.compare_positions(range.start, pos) in [:lt, :eq] &&
Sourceror.compare_positions(range.end, pos) in [:gt, :eq] do
{tree, node}
else
{tree, acc}
end
end)

case result do
{_, nil} ->
{:error, "could not find a module to alias at the cursor position"}

{_, {_t, _m, []}} ->
{:error, "could not find a module to alias at the cursor position"}

{_, {_t, _m, [_argument | _rest]} = node} ->
{:ok, node}
end
end

defp get_aliased(defm, modules) do
last = List.last(modules)

replaced =
Macro.prewalk(defm, fn
{:__aliases__, context, ^modules} -> {:__aliases__, context, [last]}
ast -> ast
end)

alias_to_add = {:alias, [alias: false], [{:__aliases__, [], modules}]}

{:defmodule, context, [module, [{do_block, block}]]} = replaced

case block do
{:__block__, block_context, defs} ->
{:defmodule, context, [module, [{do_block, {:__block__, block_context, [alias_to_add | defs]}}]]}

{_, _, _} = original ->
{:defmodule, context, [module, [{do_block, {:__block__, [], [alias_to_add, original]}}]]}
end
end
end
24 changes: 2 additions & 22 deletions lib/next_ls/extensions/elixir_extension/code_action/require.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule NextLS.ElixirExtension.CodeAction.Require do
alias GenLSP.Structures.Range
alias GenLSP.Structures.TextEdit
alias GenLSP.Structures.WorkspaceEdit
alias NextLS.ASTHelpers

@one_indentation_level " "
@spec new(diagnostic :: Diagnostic.t(), [text :: String.t()], uri :: String.t()) :: [CodeAction.t()]
Expand All @@ -15,7 +16,7 @@ defmodule NextLS.ElixirExtension.CodeAction.Require do

with {:ok, require_module} <- get_edit(diagnostic.message),
{:ok, ast} <- parse_ast(text),
{:ok, defm} <- nearest_defmodule(ast, range),
{:ok, defm} <- ASTHelpers.get_surrounding_module(ast, range.start),
indentation <- get_indent(text, defm),
nearest <- find_nearest_node_for_require(defm),
range <- get_edit_range(nearest) do
Expand Down Expand Up @@ -47,27 +48,6 @@ defmodule NextLS.ElixirExtension.CodeAction.Require do
|> 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
Expand Down
24 changes: 24 additions & 0 deletions lib/next_ls/helpers/ast_helpers.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
defmodule NextLS.ASTHelpers do
@moduledoc false
alias GenLSP.Structures.Position
alias Sourceror.Zipper

defmodule Attributes do
Expand Down Expand Up @@ -154,6 +155,29 @@ defmodule NextLS.ASTHelpers do
end)
end

@spec get_surrounding_module(ast :: Macro.t(), position :: Position.t()) :: {:ok, Macro.t()} | {:error, String.t()}
def get_surrounding_module(ast, position) do
defm =
ast
|> Macro.prewalker()
|> Enum.filter(fn node -> match?({:defmodule, _, _}, node) end)
|> Enum.filter(fn {_, ctx, _} ->
position.line + 1 - ctx[:line] >= 0
end)
|> Enum.min_by(
fn {_, ctx, _} ->
abs(ctx[:line] - 1 - position.line)
end,
fn -> nil end
)

if defm do
{:ok, defm}
else
{:error, "no defmodule definition"}
end
end

def find_cursor(ast) do
with nil <-
ast
Expand Down
91 changes: 91 additions & 0 deletions test/next_ls/alias_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
defmodule NextLS.AliasTest do
use ExUnit.Case, async: true

import GenLSP.Test
import NextLS.Support.Utils

@moduletag :tmp_dir
@moduletag root_paths: ["my_proj"]

setup %{tmp_dir: tmp_dir} do
File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib"))
File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), mix_exs())

cwd = Path.join(tmp_dir, "my_proj")

foo_path = Path.join(cwd, "lib/foo.ex")

foo = """
defmodule Foo do
def to_list() do
Foo.Bar.to_list(Map.new())
end

def to_map() do
Foo.Bar.to_map(List.new())
end
end
"""

File.write!(foo_path, foo)

[foo: foo, foo_path: foo_path]
end

setup :with_lsp

setup context do
assert :ok == notify(context.client, %{method: "initialized", jsonrpc: "2.0", params: %{}})
assert_is_ready(context, "my_proj")
assert_compiled(context, "my_proj")
assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}}

did_open(context.client, context.foo_path, context.foo)
context
end

test "refactors with alias", %{client: client, foo_path: foo} do
foo_uri = uri(foo)
id = 1

request client, %{
method: "workspace/executeCommand",
id: id,
jsonrpc: "2.0",
params: %{
command: "alias-refactor",
arguments: [%{uri: foo_uri, position: %{line: 2, character: 8}}]
}
}

expected_edit =
String.trim("""
defmodule Foo do
alias Foo.Bar

def to_list() do
Bar.to_list(Map.new())
end

def to_map() do
Bar.to_map(List.new())
end
end
""")

assert_request(client, "workspace/applyEdit", 500, fn params ->
assert %{"edit" => edit, "label" => "Refactored with an alias"} = params

assert %{
"changes" => %{
^foo_uri => [%{"newText" => text, "range" => range}]
}
} = edit

assert text == expected_edit

assert range["start"] == %{"character" => 0, "line" => 0}
assert range["end"] == %{"character" => 3, "line" => 8}
end)
end
end
Loading
Loading