Skip to content

Commit af7ba81

Browse files
Add expandMacro custom command (#498)
* add expandMacro command * deprecate custom elixirDocument/macroExpansion method * refactor command executor * add tests * Apply suggestions from code review Co-authored-by: Jason Axelson <[email protected]> * Fix formatting Co-authored-by: Jason Axelson <[email protected]> Co-authored-by: Jason Axelson <[email protected]>
1 parent e1538ed commit af7ba81

File tree

6 files changed

+268
-87
lines changed

6 files changed

+268
-87
lines changed

apps/language_server/lib/language_server/protocol.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ defmodule ElixirLS.LanguageServer.Protocol do
190190
end
191191
end
192192

193+
# TODO remove in ElixirLS 0.8
193194
defmacro macro_expansion(id, whole_buffer, selected_macro, macro_line) do
194195
quote do
195196
request(unquote(id), "elixirDocument/macroExpansion", %{

apps/language_server/lib/language_server/providers/execute_command.ex

Lines changed: 13 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -3,94 +3,21 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand do
33
Adds a @spec annotation to the document when the user clicks on a code lens.
44
"""
55

6-
alias ElixirLS.LanguageServer.{JsonRpc, SourceFile}
7-
import ElixirLS.LanguageServer.Protocol
8-
alias ElixirLS.LanguageServer.Server
9-
10-
@default_target_line_length 98
11-
12-
def execute("spec:" <> _, args, state) do
13-
[
14-
%{
15-
"uri" => uri,
16-
"mod" => mod,
17-
"fun" => fun,
18-
"arity" => arity,
19-
"spec" => spec,
20-
"line" => line
21-
}
22-
] = args
23-
24-
mod = String.to_atom(mod)
25-
fun = String.to_atom(fun)
26-
27-
source_file = Server.get_source_file(state, uri)
28-
29-
cur_text = source_file.text
30-
31-
# In case line has changed since this suggestion was generated, look for the function's current
32-
# line number and fall back to the previous line number if we can't guess the new one
33-
line =
34-
if SourceFile.function_def_on_line?(cur_text, line, fun) do
35-
line
36-
else
37-
new_line = SourceFile.function_line(mod, fun, arity)
38-
39-
if SourceFile.function_def_on_line?(cur_text, line, fun) do
40-
new_line
41-
else
42-
raise "Function definition has moved since suggestion was generated. " <>
43-
"Try again after file has been recompiled."
44-
end
45-
end
46-
47-
cur_line = Enum.at(SourceFile.lines(cur_text), line - 1)
48-
[indentation] = Regex.run(Regex.recompile!(~r/^\s*/), cur_line)
49-
50-
# Attempt to format to fit within the preferred line length, fallback to having it all on one
51-
# line if anything fails
52-
formatted =
53-
try do
54-
target_line_length =
55-
case SourceFile.formatter_opts(uri) do
56-
{:ok, opts} -> Keyword.get(opts, :line_length, @default_target_line_length)
57-
:error -> @default_target_line_length
58-
end
59-
60-
target_line_length = target_line_length - String.length(indentation)
61-
62-
Code.format_string!("@spec #{spec}", line_length: target_line_length)
63-
|> IO.iodata_to_binary()
64-
|> SourceFile.lines()
65-
|> Enum.map(&(indentation <> &1))
66-
|> Enum.join("\n")
67-
|> Kernel.<>("\n")
68-
rescue
69-
_ ->
70-
"#{indentation}@spec #{spec}\n"
6+
@callback execute(String.t(), [any], %ElixirLS.LanguageServer.Server{}) ::
7+
{:ok, any} | {:error, atom, String.t()}
8+
9+
def execute(command, args, state) do
10+
handler =
11+
case command do
12+
"spec:" <> _ -> ElixirLS.LanguageServer.Providers.ExecuteCommand.ApplySpec
13+
"expandMacro" -> ElixirLS.LanguageServer.Providers.ExecuteCommand.ExpandMacro
14+
_ -> nil
7115
end
7216

73-
edit_result =
74-
JsonRpc.send_request("workspace/applyEdit", %{
75-
"label" => "Add @spec to #{mod}.#{fun}/#{arity}",
76-
"edit" => %{
77-
"changes" => %{
78-
uri => [%{"range" => range(line - 1, 0, line - 1, 0), "newText" => formatted}]
79-
}
80-
}
81-
})
82-
83-
case edit_result do
84-
{:ok, %{"applied" => true}} ->
85-
{:ok, nil}
86-
87-
other ->
88-
{:error, :server_error,
89-
"cannot insert spec, workspace/applyEdit returned #{inspect(other)}"}
17+
if handler do
18+
handler.execute(command, args, state)
19+
else
20+
{:error, :invalid_request, nil}
9021
end
9122
end
92-
93-
def execute(_command, _args, _state) do
94-
{:error, :invalid_request, nil}
95-
end
9623
end
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ApplySpec do
2+
@moduledoc """
3+
This module implements a custom command inserting dialyzer suggested function spec.
4+
Generates source file edit as a result.
5+
"""
6+
7+
alias ElixirLS.LanguageServer.{JsonRpc, SourceFile}
8+
import ElixirLS.LanguageServer.Protocol
9+
alias ElixirLS.LanguageServer.Server
10+
11+
@behaviour ElixirLS.LanguageServer.Providers.ExecuteCommand
12+
13+
@default_target_line_length 98
14+
15+
@impl ElixirLS.LanguageServer.Providers.ExecuteCommand
16+
def execute("spec:" <> _, args, state) do
17+
[
18+
%{
19+
"uri" => uri,
20+
"mod" => mod,
21+
"fun" => fun,
22+
"arity" => arity,
23+
"spec" => spec,
24+
"line" => line
25+
}
26+
] = args
27+
28+
mod = String.to_atom(mod)
29+
fun = String.to_atom(fun)
30+
31+
source_file = Server.get_source_file(state, uri)
32+
33+
cur_text = source_file.text
34+
35+
# In case line has changed since this suggestion was generated, look for the function's current
36+
# line number and fall back to the previous line number if we can't guess the new one
37+
line =
38+
if SourceFile.function_def_on_line?(cur_text, line, fun) do
39+
line
40+
else
41+
new_line = SourceFile.function_line(mod, fun, arity)
42+
43+
if SourceFile.function_def_on_line?(cur_text, line, fun) do
44+
new_line
45+
else
46+
raise "Function definition has moved since suggestion was generated. " <>
47+
"Try again after file has been recompiled."
48+
end
49+
end
50+
51+
cur_line = Enum.at(SourceFile.lines(cur_text), line - 1)
52+
[indentation] = Regex.run(Regex.recompile!(~r/^\s*/), cur_line)
53+
54+
# Attempt to format to fit within the preferred line length, fallback to having it all on one
55+
# line if anything fails
56+
formatted =
57+
try do
58+
target_line_length =
59+
case SourceFile.formatter_opts(uri) do
60+
{:ok, opts} -> Keyword.get(opts, :line_length, @default_target_line_length)
61+
:error -> @default_target_line_length
62+
end
63+
64+
target_line_length = target_line_length - String.length(indentation)
65+
66+
Code.format_string!("@spec #{spec}", line_length: target_line_length)
67+
|> IO.iodata_to_binary()
68+
|> SourceFile.lines()
69+
|> Enum.map(&(indentation <> &1))
70+
|> Enum.join("\n")
71+
|> Kernel.<>("\n")
72+
rescue
73+
_ ->
74+
"#{indentation}@spec #{spec}\n"
75+
end
76+
77+
edit_result =
78+
JsonRpc.send_request("workspace/applyEdit", %{
79+
"label" => "Add @spec to #{mod}.#{fun}/#{arity}",
80+
"edit" => %{
81+
"changes" => %{
82+
uri => [%{"range" => range(line - 1, 0, line - 1, 0), "newText" => formatted}]
83+
}
84+
}
85+
})
86+
87+
case edit_result do
88+
{:ok, %{"applied" => true}} ->
89+
{:ok, nil}
90+
91+
other ->
92+
{:error, :server_error,
93+
"cannot insert spec, workspace/applyEdit returned #{inspect(other)}"}
94+
end
95+
end
96+
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ExpandMacro do
2+
@moduledoc """
3+
This module implements a custom command expanding an elixir macro.
4+
Returns a formatted source fragment.
5+
"""
6+
7+
alias ElixirLS.LanguageServer.Server
8+
9+
@behaviour ElixirLS.LanguageServer.Providers.ExecuteCommand
10+
11+
@impl ElixirLS.LanguageServer.Providers.ExecuteCommand
12+
def execute("expandMacro", [uri, text, line], state)
13+
when is_binary(text) and is_integer(line) do
14+
source_file = Server.get_source_file(state, uri)
15+
cur_text = source_file.text
16+
17+
if String.trim(text) != "" do
18+
formatted =
19+
ElixirSense.expand_full(cur_text, text, line + 1)
20+
|> Map.new(fn {key, value} ->
21+
key =
22+
key
23+
|> Atom.to_string()
24+
|> Macro.camelize()
25+
|> String.replace("Expand", "expand")
26+
27+
formatted = value |> Code.format_string!() |> List.to_string()
28+
{key, formatted <> "\n"}
29+
end)
30+
31+
{:ok, formatted}
32+
else
33+
# special case to avoid
34+
# warning: invalid expression (). If you want to invoke or define a function, make sure there are
35+
# no spaces between the function name and its arguments. If you wanted to pass an empty block or code,
36+
# pass a value instead, such as a nil or an atom
37+
# nofile:1
38+
{:ok,
39+
%{
40+
"expand" => "\n",
41+
"expandAll" => "\n",
42+
"expandOnce" => "\n",
43+
"expandPartial" => "\n"
44+
}}
45+
end
46+
end
47+
end

apps/language_server/lib/language_server/server.ex

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,12 @@ defmodule ElixirLS.LanguageServer.Server do
729729
end, state}
730730
end
731731

732+
# TODO remove in ElixirLS 0.8
732733
defp handle_request(macro_expansion(_id, whole_buffer, selected_macro, macro_line), state) do
734+
IO.warn(
735+
"Custom `elixirDocument/macroExpansion` request is deprecated. Switch to command `executeMacro` via `workspace/executeCommand`"
736+
)
737+
733738
x = ElixirSense.expand_full(whole_buffer, selected_macro, macro_line)
734739
{:ok, x, state}
735740
end
@@ -779,7 +784,12 @@ defmodule ElixirLS.LanguageServer.Server do
779784
"workspaceSymbolProvider" => true,
780785
"documentOnTypeFormattingProvider" => %{"firstTriggerCharacter" => "\n"},
781786
"codeLensProvider" => %{"resolveProvider" => false},
782-
"executeCommandProvider" => %{"commands" => ["spec:#{server_instance_id}"]},
787+
"executeCommandProvider" => %{
788+
"commands" => [
789+
"spec:#{server_instance_id}",
790+
"expandMacro"
791+
]
792+
},
783793
"workspace" => %{
784794
"workspaceFolders" => %{"supported" => false, "changeNotifications" => false}
785795
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ExpandMacroTest do
2+
use ExUnit.Case
3+
4+
alias ElixirLS.LanguageServer.{Server, SourceFile}
5+
alias ElixirLS.LanguageServer.Providers.ExecuteCommand.ExpandMacro
6+
7+
test "nothing to expand" do
8+
uri = "file:///some_file.ex"
9+
10+
text = """
11+
defmodule Abc do
12+
use ElixirLS.Test.MacroA
13+
end
14+
"""
15+
16+
assert {:ok, res} =
17+
ExpandMacro.execute("expandMacro", [uri, "", 1], %Server{
18+
source_files: %{
19+
uri => %SourceFile{
20+
text: text
21+
}
22+
}
23+
})
24+
25+
assert res == %{
26+
"expand" => "\n",
27+
"expandAll" => "\n",
28+
"expandOnce" => "\n",
29+
"expandPartial" => "\n"
30+
}
31+
32+
assert {:ok, res} =
33+
ExpandMacro.execute("expandMacro", [uri, "abc", 1], %Server{
34+
source_files: %{
35+
uri => %SourceFile{
36+
text: text
37+
}
38+
}
39+
})
40+
41+
assert res == %{
42+
"expand" => "abc\n",
43+
"expandAll" => "abc\n",
44+
"expandOnce" => "abc\n",
45+
"expandPartial" => "abc\n"
46+
}
47+
end
48+
49+
test "expands macro" do
50+
uri = "file:///some_file.ex"
51+
52+
text = """
53+
defmodule Abc do
54+
use ElixirLS.Test.MacroA
55+
end
56+
"""
57+
58+
assert {:ok, res} =
59+
ExpandMacro.execute("expandMacro", [uri, "use ElixirLS.Test.MacroA", 1], %Server{
60+
source_files: %{
61+
uri => %SourceFile{
62+
text: text
63+
}
64+
}
65+
})
66+
67+
assert res == %{
68+
"expand" => """
69+
require(ElixirLS.Test.MacroA)
70+
ElixirLS.Test.MacroA.__using__([])
71+
""",
72+
"expandAll" => """
73+
require(ElixirLS.Test.MacroA)
74+
75+
(
76+
import(ElixirLS.Test.MacroA)
77+
78+
def(macro_a_func) do
79+
:ok
80+
end
81+
)
82+
""",
83+
"expandOnce" => """
84+
require(ElixirLS.Test.MacroA)
85+
ElixirLS.Test.MacroA.__using__([])
86+
""",
87+
"expandPartial" => """
88+
require(ElixirLS.Test.MacroA)
89+
90+
(
91+
import(ElixirLS.Test.MacroA)
92+
93+
def(macro_a_func) do
94+
:ok
95+
end
96+
)
97+
"""
98+
}
99+
end
100+
end

0 commit comments

Comments
 (0)