Skip to content

Commit cfa7eb2

Browse files
NJichevmhanberg
andauthored
feat(commands): to-pipe (#318)
Co-authored-by: Mitchell Hanberg <[email protected]>
1 parent 1d5ba4f commit cfa7eb2

File tree

8 files changed

+677
-2
lines changed

8 files changed

+677
-2
lines changed

flake.nix

+1-1
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@
115115
src = self.outPath;
116116
inherit version elixir;
117117
pname = "next-ls-deps";
118-
hash = "sha256-GwIxmja8IcgbeKhdiQflhe5Oxq8KiYbLBNLIMkT4HBc=";
118+
hash = "sha256-BteNxUWcubVZ/SrFeBxKKV7KHmR39H50kUVaUz53dJs=";
119119
mixEnv = "prod";
120120
};
121121

lib/next_ls.ex

+59
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ defmodule NextLS do
2424
alias GenLSP.Requests.TextDocumentFormatting
2525
alias GenLSP.Requests.TextDocumentHover
2626
alias GenLSP.Requests.TextDocumentReferences
27+
alias GenLSP.Requests.WorkspaceApplyEdit
2728
alias GenLSP.Requests.WorkspaceSymbol
29+
alias GenLSP.Structures.ApplyWorkspaceEditParams
2830
alias GenLSP.Structures.CodeActionContext
2931
alias GenLSP.Structures.CodeActionOptions
3032
alias GenLSP.Structures.CodeActionParams
@@ -44,6 +46,7 @@ defmodule NextLS do
4446
alias GenLSP.Structures.TextDocumentItem
4547
alias GenLSP.Structures.TextDocumentSyncOptions
4648
alias GenLSP.Structures.TextEdit
49+
alias GenLSP.Structures.WorkspaceEdit
4750
alias GenLSP.Structures.WorkspaceFoldersChangeEvent
4851
alias NextLS.DB
4952
alias NextLS.Definition
@@ -137,6 +140,11 @@ defmodule NextLS do
137140
nil
138141
end,
139142
document_formatting_provider: true,
143+
execute_command_provider: %GenLSP.Structures.ExecuteCommandOptions{
144+
commands: [
145+
"to-pipe"
146+
]
147+
},
140148
hover_provider: true,
141149
workspace_symbol_provider: true,
142150
document_symbol_provider: true,
@@ -602,6 +610,57 @@ defmodule NextLS do
602610
{:reply, [], lsp}
603611
end
604612

613+
def handle_request(
614+
%GenLSP.Requests.WorkspaceExecuteCommand{
615+
params: %GenLSP.Structures.ExecuteCommandParams{command: command} = params
616+
},
617+
lsp
618+
) do
619+
reply =
620+
case command do
621+
"to-pipe" ->
622+
[arguments] = params.arguments
623+
624+
uri = arguments["uri"]
625+
position = arguments["position"]
626+
text = lsp.assigns.documents[uri]
627+
628+
NextLS.Commands.ToPipe.run(%{
629+
uri: uri,
630+
text: text,
631+
position: position
632+
})
633+
634+
_ ->
635+
NextLS.Logger.show_message(lsp.logger, :warning, "[Next LS] Unknown workspace command: #{command}")
636+
nil
637+
end
638+
639+
case reply do
640+
%WorkspaceEdit{} = edit ->
641+
GenLSP.request(lsp, %WorkspaceApplyEdit{
642+
id: System.unique_integer([:positive]),
643+
params: %ApplyWorkspaceEditParams{label: "Pipe", edit: edit}
644+
})
645+
646+
_reply ->
647+
:ok
648+
end
649+
650+
{:reply, reply, lsp}
651+
rescue
652+
e ->
653+
NextLS.Logger.show_message(
654+
lsp.assigns.logger,
655+
:error,
656+
"[Next LS] #{command} has failed, see the logs for more details"
657+
)
658+
659+
NextLS.Logger.error(lsp.assigns.logger, Exception.format_banner(:error, e, __STACKTRACE__))
660+
661+
{:reply, nil, lsp}
662+
end
663+
605664
def handle_request(%Shutdown{}, lsp) do
606665
{:reply, nil, assign(lsp, exit_code: 0)}
607666
end

lib/next_ls/commands/to_pipe.ex

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
defmodule NextLS.Commands.ToPipe do
2+
@moduledoc false
3+
import Schematic
4+
5+
alias GenLSP.Enumerations.ErrorCodes
6+
alias GenLSP.Structures.Position
7+
alias GenLSP.Structures.Range
8+
alias GenLSP.Structures.TextEdit
9+
alias GenLSP.Structures.WorkspaceEdit
10+
alias NextLS.EditHelpers
11+
alias Sourceror.Zipper, as: Z
12+
13+
defp opts do
14+
map(%{
15+
position: Position.schematic(),
16+
uri: str(),
17+
text: list(str())
18+
})
19+
end
20+
21+
def run(opts) do
22+
with {:ok, %{text: text, uri: uri, position: position}} <- unify(opts(), Map.new(opts)),
23+
{:ok, ast} = parse(text),
24+
{:ok, {t, m, [argument | rest]} = original} <- get_node(ast, position) do
25+
dbg(original)
26+
range = Sourceror.get_range(original)
27+
dbg(range)
28+
text |> Enum.join("\n") |> NextLS.Commands.ToPipe.decorate(range) |> dbg()
29+
range = make_range(original)
30+
indent = EditHelpers.get_indent(text, range.start.line)
31+
piped = {:|>, [], [argument, {t, m, rest}]}
32+
33+
%WorkspaceEdit{
34+
changes: %{
35+
uri => [
36+
%TextEdit{
37+
new_text:
38+
EditHelpers.add_indent_to_edit(
39+
Macro.to_string(piped),
40+
indent
41+
),
42+
range: range
43+
}
44+
]
45+
}
46+
}
47+
else
48+
{:error, message} ->
49+
%GenLSP.ErrorResponse{code: ErrorCodes.parse_error(), message: inspect(message)}
50+
end
51+
end
52+
53+
defp parse(lines) do
54+
lines
55+
|> Enum.join("\n")
56+
|> Spitfire.parse()
57+
|> case do
58+
{:error, ast, _errors} ->
59+
{:ok, ast}
60+
61+
other ->
62+
other
63+
end
64+
end
65+
66+
def decorate(code, range) do
67+
code
68+
|> Sourceror.patch_string([%{range: range, change: &#{&1}»"}])
69+
|> String.trim_trailing()
70+
end
71+
72+
defp make_range(original_ast) do
73+
range = Sourceror.get_range(original_ast)
74+
75+
%Range{
76+
start: %Position{line: range.start[:line] - 1, character: range.start[:column] - 1},
77+
end: %Position{line: range.end[:line] - 1, character: range.end[:column] - 1}
78+
}
79+
end
80+
81+
def get_node(ast, pos) do
82+
pos = [line: pos.line + 1, column: pos.character + 1]
83+
84+
result =
85+
ast
86+
|> Z.zip()
87+
|> Z.traverse(nil, fn tree, acc ->
88+
node = Z.node(tree)
89+
range = Sourceror.get_range(node)
90+
91+
if not is_nil(range) and
92+
(match?({{:., _, _}, _, [_ | _]}, node) or
93+
match?({t, _, [_ | _]} when t not in [:., :__aliases__], node)) do
94+
if Sourceror.compare_positions(range.start, pos) == :lt &&
95+
Sourceror.compare_positions(range.end, pos) == :gt do
96+
{tree, node}
97+
else
98+
{tree, acc}
99+
end
100+
else
101+
{tree, acc}
102+
end
103+
end)
104+
105+
case result do
106+
{_, nil} ->
107+
{:error, "could not find an argument to extract at the cursor position"}
108+
109+
{_, {_t, _m, []}} ->
110+
{:error, "could not find an argument to extract at the cursor position"}
111+
112+
{_, {_t, _m, [_argument | _rest]} = node} ->
113+
{:ok, node}
114+
end
115+
end
116+
end

lib/next_ls/helpers/edit_helpers.ex

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
defmodule NextLS.EditHelpers do
2+
@moduledoc false
3+
4+
@doc """
5+
This adds indentation to all lines except the first since the LSP expects a range for edits,
6+
where we get the range with the already original indentation for starters.
7+
8+
It also skips empty lines since they don't need indentation.
9+
"""
10+
@spec add_indent_to_edit(text :: String.t(), indent :: String.t()) :: String.t()
11+
@blank_lines ["", "\n"]
12+
def add_indent_to_edit(text, indent) do
13+
[first | rest] = String.split(text, "\n")
14+
15+
if rest != [] do
16+
indented =
17+
Enum.map_join(rest, "\n", fn line ->
18+
if line not in @blank_lines do
19+
indent <> line
20+
else
21+
line
22+
end
23+
end)
24+
25+
first <> "\n" <> indented
26+
else
27+
first
28+
end
29+
end
30+
31+
@doc """
32+
Gets the indentation level at the line number desired
33+
"""
34+
@spec get_indent(text :: [String.t()], line :: non_neg_integer()) :: String.t()
35+
def get_indent(text, line) do
36+
text
37+
|> Enum.at(line)
38+
|> then(&Regex.run(~r/^(\s*).*/, &1))
39+
|> List.last()
40+
end
41+
end

mix.exs

+1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ defmodule NextLS.MixProject do
6363
{:req, "~> 0.3"},
6464
{:schematic, "~> 0.2"},
6565
{:spitfire, github: "elixir-tools/spitfire"},
66+
{:sourceror, "~> 1.0"},
6667

6768
{:opentelemetry, "~> 1.3"},
6869
{:opentelemetry_api, "~> 1.2"},

mix.lock

+2-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
4444
"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"},
4545
"schematic": {:hex, :schematic, "0.2.1", "0b091df94146fd15a0a343d1bd179a6c5a58562527746dadd09477311698dbb1", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0b255d65921e38006138201cd4263fd8bb807d9dfc511074615cd264a571b3b1"},
46-
"spitfire": {:git, "https://github.com/elixir-tools/spitfire.git", "12a1827821265170a58e40b5ffd2bb785f789d91", []},
46+
"sourceror": {:hex, :sourceror, "1.0.1", "ec2c41726d181adce888ac94b3f33b359a811b46e019c084509e02c70042e424", [:mix], [], "hexpm", "28225464ffd68bda1843c974f3ff7ccef35e29be09a65dfe8e3df3f7e3600c57"},
47+
"spitfire": {:git, "https://github.com/elixir-tools/spitfire.git", "adb18c8f4479ddddf2eef844211e0861bd856fdb", []},
4748
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
4849
"styler": {:hex, :styler, "0.8.1", "f3c0f65023e4bfbf7e7aa752d128b8475fdabfd30f96ee7314b84480cc56e788", [:mix], [], "hexpm", "1aa48d3aa689a639289af3d8254d40e068e98c083d6e5e3d1a695e71a147b344"},
4950
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},

test/next_ls/commands/pipe_test.exs

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
defmodule NextLS.Commands.PipeTest do
2+
use ExUnit.Case, async: true
3+
4+
import GenLSP.Test
5+
import NextLS.Support.Utils
6+
7+
@moduletag :tmp_dir
8+
@moduletag root_paths: ["my_proj"]
9+
10+
setup %{tmp_dir: tmp_dir} do
11+
File.mkdir_p!(Path.join(tmp_dir, "my_proj/lib"))
12+
File.write!(Path.join(tmp_dir, "my_proj/mix.exs"), mix_exs())
13+
14+
cwd = Path.join(tmp_dir, "my_proj")
15+
16+
foo_path = Path.join(cwd, "lib/foo.ex")
17+
18+
foo = """
19+
defmodule Foo do
20+
def to_list() do
21+
Enum.to_list(Map.new())
22+
end
23+
end
24+
"""
25+
26+
File.write!(foo_path, foo)
27+
28+
bar_path = Path.join(cwd, "lib/bar.ex")
29+
30+
bar = """
31+
defmodule Bar do
32+
def to_list() do
33+
Map.new() |> Enum.to_list()
34+
end
35+
end
36+
"""
37+
38+
File.write!(bar_path, bar)
39+
40+
[foo: foo, foo_path: foo_path, bar: bar, bar_path: bar_path]
41+
end
42+
43+
setup :with_lsp
44+
45+
setup context do
46+
assert :ok == notify(context.client, %{method: "initialized", jsonrpc: "2.0", params: %{}})
47+
assert_is_ready(context, "my_proj")
48+
assert_compiled(context, "my_proj")
49+
assert_notification "$/progress", %{"value" => %{"kind" => "end", "message" => "Finished indexing!"}}
50+
51+
did_open(context.client, context.foo_path, context.foo)
52+
did_open(context.client, context.bar_path, context.bar)
53+
context
54+
end
55+
56+
test "transforms nested function expressions to pipes", %{client: client, foo_path: foo} do
57+
foo_uri = uri(foo)
58+
id = 1
59+
60+
request client, %{
61+
method: "workspace/executeCommand",
62+
id: id,
63+
jsonrpc: "2.0",
64+
params: %{
65+
command: "to-pipe",
66+
arguments: [%{uri: foo_uri, position: %{line: 2, character: 19}}]
67+
}
68+
}
69+
70+
assert_request(client, "workspace/applyEdit", 500, fn params ->
71+
assert %{"edit" => edit, "label" => "Pipe"} = params
72+
73+
assert %{
74+
"changes" => %{
75+
^foo_uri => [%{"newText" => text, "range" => range}]
76+
}
77+
} = edit
78+
79+
expected = "Map.new() |> Enum.to_list()"
80+
assert text == expected
81+
assert range["start"] == %{"character" => 4, "line" => 2}
82+
assert range["end"] == %{"character" => 27, "line" => 2}
83+
end)
84+
end
85+
end

0 commit comments

Comments
 (0)