Skip to content

Commit

Permalink
feat(commands): from-pipe
Browse files Browse the repository at this point in the history
  • Loading branch information
mhanberg committed Feb 25, 2024
1 parent 5ba29e5 commit aa9f957
Show file tree
Hide file tree
Showing 4 changed files with 352 additions and 1 deletion.
16 changes: 15 additions & 1 deletion lib/next_ls.ex
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ defmodule NextLS do
document_formatting_provider: true,
execute_command_provider: %GenLSP.Structures.ExecuteCommandOptions{
commands: [
"to-pipe"
"to-pipe",
"from-pipe"
]
},
hover_provider: true,
Expand Down Expand Up @@ -618,6 +619,19 @@ defmodule NextLS do
) do
reply =
case command do
"from-pipe" ->
[arguments] = params.arguments

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

NextLS.Commands.FromPipe.new(%{
uri: uri,
text: text,
position: position
})

"to-pipe" ->
[arguments] = params.arguments

Expand Down
104 changes: 104 additions & 0 deletions lib/next_ls/commands/from_pipe.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
defmodule NextLS.Commands.FromPipe do
@moduledoc false
alias GenLSP.Enumerations.ErrorCodes
alias GenLSP.Structures.Position
alias GenLSP.Structures.Range
alias GenLSP.Structures.TextEdit
alias GenLSP.Structures.WorkspaceEdit
alias NextLS.EditHelpers

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

def new(opts) do
with {:ok, %{text: text, uri: uri, position: position}} <- Schematic.unify(opts(), Map.new(opts)),
{:ok, %TextEdit{} = edit} <- from_pipe_edit(text, position) do

Check warning on line 20 in lib/next_ls/commands/from_pipe.ex

View workflow job for this annotation

GitHub Actions / dialyzer

pattern_match

The pattern can never match the type {:error, <<_::248>>} | {:error, _, [any()]}.
%WorkspaceEdit{
changes: %{
uri => [edit]
}
}
else
{:error, message} ->
%GenLSP.ErrorResponse{code: ErrorCodes.parse_error(), message: inspect(message)}

{:error, _ast, messages} ->
error =
Enum.map_join(messages, "\n", fn {ctx, message} ->
message <> " on line: #{ctx[:line]}, column: #{ctx[:column]}"
end)

%GenLSP.ErrorResponse{code: ErrorCodes.parse_error(), message: error}
end
end

defp find_pipe_lines(ast, position) do
pipes =
ast
|> Macro.postwalker()
|> Enum.filter(fn
{:|>, context, _} -> (context[:line] || 0) - 1 == position.line
_ -> false
end)

if pipes != [] do
pipe_to_inline = Enum.min_by(pipes, fn {_, context, _} -> abs(position.character - context[:column]) end)
{:|>, _ctx, [{_, lhs, _}, {_, rhs, _}]} = pipe_to_inline
open = lhs
closing = rhs[:closing] || rhs[:end_of_expression] || rhs[:end]

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

{:ok, pipe_to_inline, range}
else
{:error, "could not find a pipe to inline"}
end
end

defp from_pipe_edit(text, position) do
with {:ok, ast} <- code_to_ast(text),
{:ok, ast_to_edit, range} <- find_pipe_lines(ast, position),

Check warning on line 68 in lib/next_ls/commands/from_pipe.ex

View workflow job for this annotation

GitHub Actions / dialyzer

pattern_match

The pattern can never match the type binary().
{:ok, indent} = EditHelpers.get_indent(text, position.line),
edit <- get_edit(ast_to_edit, indent) do
{:ok, %TextEdit{new_text: edit, range: range}}
else
error -> error
end
end

defp code_to_ast(lines) do
lines
|> Enum.join("\n")
|> Spitfire.parse()
end

defp get_edit(ast, indent) do

Check warning on line 83 in lib/next_ls/commands/from_pipe.ex

View workflow job for this annotation

GitHub Actions / dialyzer

unused_fun

Function get_edit/2 will never be called.
ast
|> inline_pipe()
|> Macro.to_string()
|> EditHelpers.add_indent_to_edit(indent)
end

defp inline_pipe(ast) do

Check warning on line 90 in lib/next_ls/commands/from_pipe.ex

View workflow job for this annotation

GitHub Actions / dialyzer

unused_fun

Function inline_pipe/1 will never be called.
{result, _} =
Macro.postwalk(ast, _changed = false, fn
{:|>, _context, [left_arg, right_arg]}, false ->
{call, context, args} = right_arg
ast = {call, context, [left_arg | args]}
{ast, true}

ast, acc ->
{ast, acc}
end)

result
end
end
203 changes: 203 additions & 0 deletions test/next_ls/commands/from_pipe_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
defmodule NextLS.Commands.FromPipeTest do
use ExUnit.Case, async: true

alias GenLSP.Structures.TextEdit
alias GenLSP.Structures.WorkspaceEdit
alias NextLS.Commands.FromPipe

@parse_error_code -32_700

describe "from-pipe" do
test "works on one liners" do
uri = "my_app.ex"

text =
String.split(
"""
defmodule MyApp do
def to_list(map) do
map |> Enum.to_list()
end
end
""",
"\n"
)

position = %{"line" => 2, "character" => 9}
expected_line = Enum.at(text, 2)

assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} =
FromPipe.new(%{uri: uri, text: text, position: position})

assert edit.new_text == "Enum.to_list(map)"
assert range.start.line == 2
assert range.start.character == 4
assert range.end.line == 2
assert range.end.character == String.length(expected_line)
end

test "works on one liners with multiple pipes" do
uri = "my_app.ex"

text =
String.split(
"""
defmodule MyApp do
def to_list(map) do
map |> Enum.to_list() |> Map.new()
end
end
""",
"\n"
)

position = %{"line" => 2, "character" => 9}

assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} =
FromPipe.new(%{uri: uri, text: text, position: position})

assert edit.new_text == "Enum.to_list(map)"
assert range.start.line == 2
assert range.start.character == 4
assert range.end.line == 2
assert range.end.character == 25
end

test "works on separate lines when the cursor is on the pipe" do
# When the cursor is on the pipe
# We should get the line before it to build the ast
uri = "my_app.ex"

text =
String.split(
"""
defmodule MyApp do
def to_list(map) do
map
|> Enum.to_list()
|> Map.new()
end
end
""",
"\n"
)

position = %{"line" => 3, "character" => 5}
expected_line = Enum.at(text, 3)

assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} =
FromPipe.new(%{uri: uri, text: text, position: position})

assert edit.new_text == "Enum.to_list(map)"
assert range.start.line == 2
assert range.start.character == 4
assert range.end.line == 3
assert range.end.character == String.length(expected_line)
end

test "works on separate lines when the cursor is on the var" do
# When the cursor is on the var
# we should get the next line to build the ast
uri = "my_app.ex"

text =
String.split(
"""
defmodule MyApp do
def to_list(map) do
map
|> Enum.to_list()
|> Map.new()
end
end
""",
"\n"
)

position = %{"line" => 3, "character" => 5}
expected_line = Enum.at(text, 3)

assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} =
FromPipe.new(%{uri: uri, text: text, position: position})

assert edit.new_text == "Enum.to_list(map)"
assert range.start.line == 2
assert range.start.character == 4
assert range.end.line == 3
assert range.end.character == String.length(expected_line)
end

test "we get an error reply if the ast is bad" do
uri = "my_app.ex"

text =
String.split(
"""
defmodule MyApp do
def to_list(map) do
|> map
|> Enum.to_list()
end
end
""",
"\n"
)

position = %{"line" => 3, "character" => 5}

assert %GenLSP.ErrorResponse{code: @parse_error_code, message: message} =
FromPipe.new(%{uri: uri, text: text, position: position})

assert message =~ "unknown token: end on line: 6, column: 1"
end

test "we handle schematic errors" do
assert %GenLSP.ErrorResponse{code: @parse_error_code, message: message} = FromPipe.new(%{bad_arg: :is_very_bad})

assert message =~ "position: \"expected a map\""
end

test "handles a pipe expression on multiple lines" do
uri = "my_app.ex"

text =
String.split(
"""
defmodule MyApp do
def all_odd?(map) do
map
|> Enum.all?(fn {x, y} ->
Integer.is_odd(y)
end)
end
end
""",
"\n"
)

expected_edit =
String.trim_trailing("""
Enum.all?(
map,
fn {x, y} ->
Integer.is_odd(y)
end
)
""")

# When the position is on `map` and on the cursor
position = %{"line" => 3, "character" => 10}

expected_line = Enum.at(text, 5)

assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} =
FromPipe.new(%{uri: uri, text: text, position: position})

assert edit.new_text == expected_edit
assert range.start.line == 2
assert range.start.character == 4
assert range.end.line == 5
assert range.end.character == String.length(expected_line)
end
end
end
30 changes: 30 additions & 0 deletions test/next_ls/commands/pipe_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,34 @@ defmodule NextLS.Commands.PipeTest do
assert range["end"] == %{"character" => 27, "line" => 2}
end)
end

test "transforms pipes to function expressions", %{client: client, bar_path: bar} do
bar_uri = uri(bar)
id = 2

request client, %{
method: "workspace/executeCommand",
id: id,
jsonrpc: "2.0",
params: %{
command: "from-pipe",
arguments: [%{uri: bar_uri, position: %{line: 2, character: 0}}]
}
}

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

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

expected = "Enum.to_list(Map.new())"
assert text == expected
assert range["start"] == %{"character" => 8, "line" => 2}
assert range["end"] == %{"character" => 31, "line" => 2}
end)
end
end

0 comments on commit aa9f957

Please sign in to comment.