Skip to content

Commit

Permalink
feat(commands): to-pipe (#318)
Browse files Browse the repository at this point in the history
Co-authored-by: Mitchell Hanberg <mitch@mitchellhanberg.com>
  • Loading branch information
NJichev and mhanberg committed Feb 26, 2024
1 parent 1d5ba4f commit cfa7eb2
Show file tree
Hide file tree
Showing 8 changed files with 677 additions and 2 deletions.
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
src = self.outPath;
inherit version elixir;
pname = "next-ls-deps";
hash = "sha256-GwIxmja8IcgbeKhdiQflhe5Oxq8KiYbLBNLIMkT4HBc=";
hash = "sha256-BteNxUWcubVZ/SrFeBxKKV7KHmR39H50kUVaUz53dJs=";
mixEnv = "prod";
};

Expand Down
59 changes: 59 additions & 0 deletions lib/next_ls.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ defmodule NextLS do
alias GenLSP.Requests.TextDocumentFormatting
alias GenLSP.Requests.TextDocumentHover
alias GenLSP.Requests.TextDocumentReferences
alias GenLSP.Requests.WorkspaceApplyEdit
alias GenLSP.Requests.WorkspaceSymbol
alias GenLSP.Structures.ApplyWorkspaceEditParams
alias GenLSP.Structures.CodeActionContext
alias GenLSP.Structures.CodeActionOptions
alias GenLSP.Structures.CodeActionParams
Expand All @@ -44,6 +46,7 @@ defmodule NextLS do
alias GenLSP.Structures.TextDocumentItem
alias GenLSP.Structures.TextDocumentSyncOptions
alias GenLSP.Structures.TextEdit
alias GenLSP.Structures.WorkspaceEdit
alias GenLSP.Structures.WorkspaceFoldersChangeEvent
alias NextLS.DB
alias NextLS.Definition
Expand Down Expand Up @@ -137,6 +140,11 @@ defmodule NextLS do
nil
end,
document_formatting_provider: true,
execute_command_provider: %GenLSP.Structures.ExecuteCommandOptions{
commands: [
"to-pipe"
]
},
hover_provider: true,
workspace_symbol_provider: true,
document_symbol_provider: true,
Expand Down Expand Up @@ -602,6 +610,57 @@ defmodule NextLS do
{:reply, [], lsp}
end

def handle_request(
%GenLSP.Requests.WorkspaceExecuteCommand{
params: %GenLSP.Structures.ExecuteCommandParams{command: command} = params
},
lsp
) do
reply =
case command do
"to-pipe" ->
[arguments] = params.arguments

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

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

_ ->
NextLS.Logger.show_message(lsp.logger, :warning, "[Next LS] Unknown workspace command: #{command}")
nil
end

case reply do
%WorkspaceEdit{} = edit ->
GenLSP.request(lsp, %WorkspaceApplyEdit{
id: System.unique_integer([:positive]),
params: %ApplyWorkspaceEditParams{label: "Pipe", edit: edit}
})

_reply ->
:ok
end

{:reply, reply, lsp}
rescue
e ->
NextLS.Logger.show_message(
lsp.assigns.logger,
:error,
"[Next LS] #{command} has failed, see the logs for more details"
)

NextLS.Logger.error(lsp.assigns.logger, Exception.format_banner(:error, e, __STACKTRACE__))

{:reply, nil, lsp}
end

def handle_request(%Shutdown{}, lsp) do
{:reply, nil, assign(lsp, exit_code: 0)}
end
Expand Down
116 changes: 116 additions & 0 deletions lib/next_ls/commands/to_pipe.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
defmodule NextLS.Commands.ToPipe do
@moduledoc false
import Schematic

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

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} = parse(text),
{:ok, {t, m, [argument | rest]} = original} <- get_node(ast, position) do
dbg(original)
range = Sourceror.get_range(original)
dbg(range)
text |> Enum.join("\n") |> NextLS.Commands.ToPipe.decorate(range) |> dbg()
range = make_range(original)
indent = EditHelpers.get_indent(text, range.start.line)
piped = {:|>, [], [argument, {t, m, rest}]}

%WorkspaceEdit{
changes: %{
uri => [
%TextEdit{
new_text:
EditHelpers.add_indent_to_edit(
Macro.to_string(piped),
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()
|> case do
{:error, ast, _errors} ->
{:ok, ast}

other ->
other
end
end

def decorate(code, range) do
code
|> Sourceror.patch_string([%{range: range, change: &#{&1}»"}])
|> String.trim_trailing()
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?({{:., _, _}, _, [_ | _]}, node) or
match?({t, _, [_ | _]} when t not in [:., :__aliases__], node)) do
if Sourceror.compare_positions(range.start, pos) == :lt &&
Sourceror.compare_positions(range.end, pos) == :gt do
{tree, node}
else
{tree, acc}
end
else
{tree, acc}
end
end)

case result do
{_, nil} ->
{:error, "could not find an argument to extract at the cursor position"}

{_, {_t, _m, []}} ->
{:error, "could not find an argument to extract at the cursor position"}

{_, {_t, _m, [_argument | _rest]} = node} ->
{:ok, node}
end
end
end
41 changes: 41 additions & 0 deletions lib/next_ls/helpers/edit_helpers.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
defmodule NextLS.EditHelpers do
@moduledoc false

@doc """
This adds indentation to all lines except the first since the LSP expects a range for edits,
where we get the range with the already original indentation for starters.
It also skips empty lines since they don't need indentation.
"""
@spec add_indent_to_edit(text :: String.t(), indent :: String.t()) :: String.t()
@blank_lines ["", "\n"]
def add_indent_to_edit(text, indent) do
[first | rest] = String.split(text, "\n")

if rest != [] do
indented =
Enum.map_join(rest, "\n", fn line ->
if line not in @blank_lines do
indent <> line
else
line
end
end)

first <> "\n" <> indented
else
first
end
end

@doc """
Gets the indentation level at the line number desired
"""
@spec get_indent(text :: [String.t()], line :: non_neg_integer()) :: String.t()
def get_indent(text, line) do
text
|> Enum.at(line)
|> then(&Regex.run(~r/^(\s*).*/, &1))
|> List.last()
end
end
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ defmodule NextLS.MixProject do
{:req, "~> 0.3"},
{:schematic, "~> 0.2"},
{:spitfire, github: "elixir-tools/spitfire"},
{:sourceror, "~> 1.0"},

{:opentelemetry, "~> 1.3"},
{:opentelemetry_api, "~> 1.2"},
Expand Down
3 changes: 2 additions & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"req": {:hex, :req, "0.4.0", "1c759054dd64ef1b1a0e475c2d2543250d18f08395d3174c371b7746984579ce", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "f53eadc32ebefd3e5d50390356ec3a59ed2b8513f7da8c6c3f2e14040e9fe989"},
"schematic": {:hex, :schematic, "0.2.1", "0b091df94146fd15a0a343d1bd179a6c5a58562527746dadd09477311698dbb1", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0b255d65921e38006138201cd4263fd8bb807d9dfc511074615cd264a571b3b1"},
"spitfire": {:git, "https://github.com/elixir-tools/spitfire.git", "12a1827821265170a58e40b5ffd2bb785f789d91", []},
"sourceror": {:hex, :sourceror, "1.0.1", "ec2c41726d181adce888ac94b3f33b359a811b46e019c084509e02c70042e424", [:mix], [], "hexpm", "28225464ffd68bda1843c974f3ff7ccef35e29be09a65dfe8e3df3f7e3600c57"},
"spitfire": {:git, "https://github.com/elixir-tools/spitfire.git", "adb18c8f4479ddddf2eef844211e0861bd856fdb", []},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"styler": {:hex, :styler, "0.8.1", "f3c0f65023e4bfbf7e7aa752d128b8475fdabfd30f96ee7314b84480cc56e788", [:mix], [], "hexpm", "1aa48d3aa689a639289af3d8254d40e068e98c083d6e5e3d1a695e71a147b344"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
Expand Down
85 changes: 85 additions & 0 deletions test/next_ls/commands/pipe_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
defmodule NextLS.Commands.PipeTest 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
Enum.to_list(Map.new())
end
end
"""

File.write!(foo_path, foo)

bar_path = Path.join(cwd, "lib/bar.ex")

bar = """
defmodule Bar do
def to_list() do
Map.new() |> Enum.to_list()
end
end
"""

File.write!(bar_path, bar)

[foo: foo, foo_path: foo_path, bar: bar, bar_path: bar_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)
did_open(context.client, context.bar_path, context.bar)
context
end

test "transforms nested function expressions to pipes", %{client: client, foo_path: foo} do
foo_uri = uri(foo)
id = 1

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

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

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

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

0 comments on commit cfa7eb2

Please sign in to comment.