From cfa7eb267533c910f2338a4a60d49bcffcab91fe Mon Sep 17 00:00:00 2001 From: Nikola Jichev Date: Mon, 26 Feb 2024 03:24:42 +0200 Subject: [PATCH] feat(commands): to-pipe (#318) Co-authored-by: Mitchell Hanberg --- flake.nix | 2 +- lib/next_ls.ex | 59 ++++ lib/next_ls/commands/to_pipe.ex | 116 ++++++++ lib/next_ls/helpers/edit_helpers.ex | 41 +++ mix.exs | 1 + mix.lock | 3 +- test/next_ls/commands/pipe_test.exs | 85 ++++++ test/next_ls/commands/to_pipe_test.exs | 372 +++++++++++++++++++++++++ 8 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 lib/next_ls/commands/to_pipe.ex create mode 100644 lib/next_ls/helpers/edit_helpers.ex create mode 100644 test/next_ls/commands/pipe_test.exs create mode 100644 test/next_ls/commands/to_pipe_test.exs diff --git a/flake.nix b/flake.nix index 24b28d11..ff363393 100644 --- a/flake.nix +++ b/flake.nix @@ -115,7 +115,7 @@ src = self.outPath; inherit version elixir; pname = "next-ls-deps"; - hash = "sha256-GwIxmja8IcgbeKhdiQflhe5Oxq8KiYbLBNLIMkT4HBc="; + hash = "sha256-BteNxUWcubVZ/SrFeBxKKV7KHmR39H50kUVaUz53dJs="; mixEnv = "prod"; }; diff --git a/lib/next_ls.ex b/lib/next_ls.ex index 5eaa13f7..83a0a03f 100644 --- a/lib/next_ls.ex +++ b/lib/next_ls.ex @@ -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 @@ -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 @@ -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, @@ -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 diff --git a/lib/next_ls/commands/to_pipe.ex b/lib/next_ls/commands/to_pipe.ex new file mode 100644 index 00000000..25a3985e --- /dev/null +++ b/lib/next_ls/commands/to_pipe.ex @@ -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 diff --git a/lib/next_ls/helpers/edit_helpers.ex b/lib/next_ls/helpers/edit_helpers.ex new file mode 100644 index 00000000..ae729dee --- /dev/null +++ b/lib/next_ls/helpers/edit_helpers.ex @@ -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 diff --git a/mix.exs b/mix.exs index 70c2e251..974c8740 100644 --- a/mix.exs +++ b/mix.exs @@ -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"}, diff --git a/mix.lock b/mix.lock index dd25e198..045abcbe 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/test/next_ls/commands/pipe_test.exs b/test/next_ls/commands/pipe_test.exs new file mode 100644 index 00000000..22254a64 --- /dev/null +++ b/test/next_ls/commands/pipe_test.exs @@ -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 diff --git a/test/next_ls/commands/to_pipe_test.exs b/test/next_ls/commands/to_pipe_test.exs new file mode 100644 index 00000000..4f762cb8 --- /dev/null +++ b/test/next_ls/commands/to_pipe_test.exs @@ -0,0 +1,372 @@ +defmodule NextLS.Commands.ToPipeTest do + use ExUnit.Case, async: true + + alias GenLSP.Structures.TextEdit + alias GenLSP.Structures.WorkspaceEdit + alias NextLS.Commands.ToPipe + + @parse_error_code -32_700 + + describe "to-pipe" do + test "works on one liners" do + uri = "my_app.ex" + + text = + String.split( + """ + defmodule MyApp do + def to_list(map) do + Enum.to_list(map) + end + end + """, + "\n" + ) + + expected_edit = "map |> Enum.to_list()" + + line = 2 + position = %{"line" => line, "character" => 19} + + assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} = + ToPipe.run(%{uri: uri, text: text, position: position}) + + assert edit.new_text == expected_edit + assert range.start.line == line + assert range.start.character == 4 + assert range.end.line == line + assert range.end.character == 21 + end + + test "works on one liners with imports" do + uri = "my_app.ex" + + text = + String.split( + """ + defmodule MyApp do + import Enum + + def to_list(map) do + to_list(map) + end + end + """, + "\n" + ) + + line = 4 + position = %{"line" => line, "character" => 5} + expected_edit = "map |> to_list()" + + assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} = + ToPipe.run(%{uri: uri, text: text, position: position}) + + assert edit.new_text == expected_edit + assert range.start.line == line + assert range.start.character == 4 + assert range.end.line == line + assert range.end.character == 16 + end + + test "works on one liners with nested function calls" do + uri = "my_app.ex" + + text = + String.split( + """ + defmodule MyApp do + def to_list(map) do + to_list(Map.new()) + end + end + """, + "\n" + ) + + expected_edit = "Map.new() |> to_list()" + + line = 2 + position = %{"line" => line, "character" => 10} + + assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} = + ToPipe.run(%{uri: uri, text: text, position: position}) + + assert edit.new_text == expected_edit + assert range.start.line == line + assert range.start.character == 4 + assert range.end.line == line + assert range.end.character == 22 + end + + test "works on one liners with nested function calls with qualified calls" do + uri = "my_app.ex" + + text = + String.split( + """ + defmodule MyApp do + def to_list(map) do + Enum.to_list(Map.new()) + end + end + """, + "\n" + ) + + expected_edit = + String.trim_trailing(""" + Map.new() |> Enum.to_list() + """) + + line = 2 + position = %{"line" => line, "character" => 7} + + assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} = + ToPipe.run(%{uri: uri, text: text, position: position}) + + assert edit.new_text == expected_edit + assert range.start.line == line + assert range.start.character == 4 + assert range.end.line == 2 + assert range.end.character == 27 + end + + @test_scenarios [ + {6, "to_list(Map.new)", "Map.new() |> to_list()"}, + {6, "to_list(a, b, c)", "a |> to_list(b, c)"}, + {10, "Foo.Bar.baz(foo, bar)", "foo |> Foo.Bar.baz(bar)"}, + {10, "Foo.Bar.baz(foo, bar, Map.new())", "foo |> Foo.Bar.baz(bar, Map.new())"} + ] + + test "small test scenarios work" do + uri = "foo.ex" + position = %{"line" => 0, "character" => 0} + + Enum.each(@test_scenarios, fn {character, to_transform, expected} -> + dbg(to_transform) + position = %{position | "character" => character} + + assert %WorkspaceEdit{changes: %{^uri => [edit]}} = + ToPipe.run(%{uri: uri, text: [to_transform], position: position}) + + assert edit.new_text == expected + end) + end + + test "handles broken code" do + uri = "my_app.ex" + + text = + String.split( + """ + defmodule MyApp do + def bad_ast(map) do + Enum.to_list(foo + end + end + """, + "\n" + ) + + position = %{"line" => 2, "character" => 15} + + assert %WorkspaceEdit{ + change_annotations: nil, + changes: %{ + "my_app.ex" => [ + %TextEdit{ + new_text: "foo |> Enum.to_list()", + range: %GenLSP.Structures.Range{ + end: %GenLSP.Structures.Position{character: 20, line: 2}, + start: %GenLSP.Structures.Position{character: 4, line: 2} + } + } + ] + }, + document_changes: nil + } = + ToPipe.run(%{uri: uri, text: text, position: position}) + end + + test "handles bad cursor position" do + uri = "my_app.ex" + + text = + String.split( + """ + foo = [:one, two] + """, + "\n" + ) + + position = %{"line" => 0, "character" => 5} + + assert %GenLSP.ErrorResponse{code: @parse_error_code, message: message} = + ToPipe.run(%{uri: uri, text: text, position: position}) + + assert message =~ "could not find an argument to extract at the cursor position" + end + + test "handles schematic errors" do + assert %GenLSP.ErrorResponse{code: @parse_error_code, message: message} = ToPipe.run(%{bad_arg: :is_very_bad}) + + assert message =~ "position: \"expected a map\"" + end + + test "handles an expression on multiple lines" do + uri = "my_app.ex" + + text = + String.split( + """ + defmodule MyApp do + def all_odd?(map) do + Enum.all?(map, fn {x, y} -> + Integer.is_odd(y) + end) + end + end + """, + "\n" + ) + + expected_edit = + String.trim_trailing(""" + map + |> Enum.all?(fn {x, y} -> + Integer.is_odd(y) + end) + """) + + position = %{"line" => 2, "character" => 15} + + assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} = + ToPipe.run(%{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 == 4 + assert range.end.character == 8 + end + + test "handles piping into a case/if/unless" do + uri = "my_app.ex" + + text = + String.split( + """ + defmodule MyApp do + def check(result) do + case result do + {:ok, _success} -> :ok + {:error, error} -> IO.inspect(error) + end + end + end + """, + "\n" + ) + + expected_edit = + String.trim_trailing(""" + result + |> case do + {:ok, _success} -> :ok + {:error, error} -> IO.inspect(error) + end + """) + + position = %{"line" => 2, "character" => 13} + + assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} = + ToPipe.run(%{uri: uri, text: text, position: position}) + + dbg(edit.new_text) + 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 == 7 + end + + test "handles nested calls in conditionals" do + uri = "my_app.ex" + + text = + String.split( + """ + defmodule MyApp do + def check(result) do + case parse_result(result) do + {:ok, _success} -> :ok + {:error, error} -> IO.inspect(error) + end + end + end + """, + "\n" + ) + + position = %{"line" => 2, "character" => 5} + + expected_edit = + String.trim_trailing(""" + parse_result(result) + |> case do + {:ok, _success} -> :ok + {:error, error} -> IO.inspect(error) + end + """) + + assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} = + ToPipe.run(%{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 == 7 + end + + test "another case" do + uri = "my_app.ex" + + text = + String.split( + """ + Enum.map( + NextLS.ASTHelpers.Variables.list_variable_references(file, {line, col}), + fn {_name, {startl..endl, startc..endc}} -> + [file, startl, endl, startc, endc] + end + ) + """, + "\n" + ) + + expected_edit = + String.trim_trailing(""" + NextLS.ASTHelpers.Variables.list_variable_references(file, {line, col}) + |> Enum.map(fn {_name, {startl..endl, startc..endc}} -> + [file, startl, endl, startc, endc] + end) + """) + + line = 0 + position = %{"line" => line, "character" => 5} + + assert %WorkspaceEdit{changes: %{^uri => [edit = %TextEdit{range: range}]}} = + ToPipe.run(%{uri: uri, text: text, position: position}) + + dbg(range) + + assert edit.new_text == expected_edit + assert range.start.line == line + assert range.start.character == 0 + assert range.end.line == 5 + assert range.end.character == 1 + end + end +end