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: to-pipe command #318

Merged
merged 1 commit into from
Feb 26, 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
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
Loading