Skip to content

Commit 36cc9ba

Browse files
NJichevmhanberg
andcommitted
feat(commands): to-pipe
Co-authored-by: Mitchell Hanberg <[email protected]>
1 parent 1d5ba4f commit 36cc9ba

File tree

7 files changed

+659
-1
lines changed

7 files changed

+659
-1
lines changed

lib/next_ls.ex

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ 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
2829
alias GenLSP.Structures.CodeActionContext
2930
alias GenLSP.Structures.CodeActionOptions
3031
alias GenLSP.Structures.CodeActionParams
3132
alias GenLSP.Structures.Diagnostic
33+
alias GenLSP.Structures.ApplyWorkspaceEditParams
3234
alias GenLSP.Structures.DidChangeWatchedFilesParams
3335
alias GenLSP.Structures.DidChangeWorkspaceFoldersParams
3436
alias GenLSP.Structures.DidOpenTextDocumentParams
@@ -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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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, new_ast, original} <- get_node_and_new_node(ast, position) do
25+
range = make_range(original)
26+
indent = EditHelpers.get_indent(text, range.start.line)
27+
28+
%WorkspaceEdit{
29+
changes: %{
30+
uri => [
31+
%TextEdit{
32+
new_text:
33+
EditHelpers.add_indent_to_edit(
34+
Macro.to_string(new_ast),
35+
indent
36+
),
37+
range: range
38+
}
39+
]
40+
}
41+
}
42+
else
43+
{:error, message} ->
44+
%GenLSP.ErrorResponse{code: ErrorCodes.parse_error(), message: inspect(message)}
45+
46+
{:error, _ast, messages} ->
47+
error =
48+
Enum.map_join(messages, "\n", fn {ctx, message} ->
49+
message <> " on line: #{ctx[:line]}, column: #{ctx[:column]}"
50+
end)
51+
52+
%GenLSP.ErrorResponse{code: ErrorCodes.parse_error(), message: error}
53+
end
54+
end
55+
56+
defp parse(lines) do
57+
lines
58+
|> Enum.join("\n")
59+
|> Spitfire.parse(column: 0, line: 0)
60+
|> case do
61+
{:errors, ast, _errors} -> {:ok, ast}
62+
other -> other
63+
end
64+
end
65+
66+
defp make_range(original_ast) do
67+
range = Sourceror.get_range(original_ast)
68+
69+
%Range{
70+
start: %Position{line: range.start[:line], character: range.start[:column]},
71+
end: %Position{line: range.end[:line], character: range.end[:column]}
72+
}
73+
end
74+
75+
def get_node_and_new_node(ast, pos) do
76+
pos = [line: pos.line, column: pos.character]
77+
78+
result =
79+
ast
80+
|> Z.zip()
81+
|> Z.traverse(nil, fn tree, acc ->
82+
node = Z.node(tree)
83+
range = Sourceror.get_range(node)
84+
85+
if not is_nil(range) and
86+
(match?({{:., _, _}, _, [_ | _]}, node) or
87+
match?({t, _, [_ | _]} when t not in [:., :__aliases__], node)) do
88+
if Sourceror.compare_positions(range.start, pos) == :lt &&
89+
Sourceror.compare_positions(range.end, pos) == :gt do
90+
{tree, node}
91+
else
92+
{tree, acc}
93+
end
94+
else
95+
{tree, acc}
96+
end
97+
end)
98+
99+
case result do
100+
{_, nil} ->
101+
{:error, "could not find an argument to extract at the cursor position"}
102+
103+
{_, {_t, _m, []}} ->
104+
{:error, "could not find an argument to extract at the cursor position"}
105+
106+
{_, {t, m, [argument | rest]} = node} ->
107+
piped = {:|>, [], [argument, {t, m, rest}]}
108+
{:ok, piped, node}
109+
end
110+
end
111+
end

lib/next_ls/helpers/edit_helpers.ex

Lines changed: 41 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 2 additions & 1 deletion
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", "100057499a1629a88af8c38a8d6f9e324cbe3980", []},
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

Lines changed: 85 additions & 0 deletions
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)