Skip to content

Add expandMacro custom command #498

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

Merged
merged 6 commits into from
Feb 28, 2021
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
1 change: 1 addition & 0 deletions apps/language_server/lib/language_server/protocol.ex
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ defmodule ElixirLS.LanguageServer.Protocol do
end
end

# TODO remove in ElixirLS 0.8
defmacro macro_expansion(id, whole_buffer, selected_macro, macro_line) do
quote do
request(unquote(id), "elixirDocument/macroExpansion", %{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,94 +3,21 @@ defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand do
Adds a @spec annotation to the document when the user clicks on a code lens.
"""

alias ElixirLS.LanguageServer.{JsonRpc, SourceFile}
import ElixirLS.LanguageServer.Protocol
alias ElixirLS.LanguageServer.Server

@default_target_line_length 98

def execute("spec:" <> _, args, state) do
[
%{
"uri" => uri,
"mod" => mod,
"fun" => fun,
"arity" => arity,
"spec" => spec,
"line" => line
}
] = args

mod = String.to_atom(mod)
fun = String.to_atom(fun)

source_file = Server.get_source_file(state, uri)

cur_text = source_file.text

# In case line has changed since this suggestion was generated, look for the function's current
# line number and fall back to the previous line number if we can't guess the new one
line =
if SourceFile.function_def_on_line?(cur_text, line, fun) do
line
else
new_line = SourceFile.function_line(mod, fun, arity)

if SourceFile.function_def_on_line?(cur_text, line, fun) do
new_line
else
raise "Function definition has moved since suggestion was generated. " <>
"Try again after file has been recompiled."
end
end

cur_line = Enum.at(SourceFile.lines(cur_text), line - 1)
[indentation] = Regex.run(Regex.recompile!(~r/^\s*/), cur_line)

# Attempt to format to fit within the preferred line length, fallback to having it all on one
# line if anything fails
formatted =
try do
target_line_length =
case SourceFile.formatter_opts(uri) do
{:ok, opts} -> Keyword.get(opts, :line_length, @default_target_line_length)
:error -> @default_target_line_length
end

target_line_length = target_line_length - String.length(indentation)

Code.format_string!("@spec #{spec}", line_length: target_line_length)
|> IO.iodata_to_binary()
|> SourceFile.lines()
|> Enum.map(&(indentation <> &1))
|> Enum.join("\n")
|> Kernel.<>("\n")
rescue
_ ->
"#{indentation}@spec #{spec}\n"
@callback execute(String.t(), [any], %ElixirLS.LanguageServer.Server{}) ::
{:ok, any} | {:error, atom, String.t()}

def execute(command, args, state) do
handler =
case command do
"spec:" <> _ -> ElixirLS.LanguageServer.Providers.ExecuteCommand.ApplySpec
"expandMacro" -> ElixirLS.LanguageServer.Providers.ExecuteCommand.ExpandMacro
_ -> nil
end

edit_result =
JsonRpc.send_request("workspace/applyEdit", %{
"label" => "Add @spec to #{mod}.#{fun}/#{arity}",
"edit" => %{
"changes" => %{
uri => [%{"range" => range(line - 1, 0, line - 1, 0), "newText" => formatted}]
}
}
})

case edit_result do
{:ok, %{"applied" => true}} ->
{:ok, nil}

other ->
{:error, :server_error,
"cannot insert spec, workspace/applyEdit returned #{inspect(other)}"}
if handler do
handler.execute(command, args, state)
else
{:error, :invalid_request, nil}
end
end

def execute(_command, _args, _state) do
{:error, :invalid_request, nil}
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ApplySpec do
@moduledoc """
This module implements a custom command inserting dialyzer suggested function spec.
Generates source file edit as a result.
"""

alias ElixirLS.LanguageServer.{JsonRpc, SourceFile}
import ElixirLS.LanguageServer.Protocol
alias ElixirLS.LanguageServer.Server

@behaviour ElixirLS.LanguageServer.Providers.ExecuteCommand

@default_target_line_length 98

@impl ElixirLS.LanguageServer.Providers.ExecuteCommand
def execute("spec:" <> _, args, state) do
[
%{
"uri" => uri,
"mod" => mod,
"fun" => fun,
"arity" => arity,
"spec" => spec,
"line" => line
}
] = args

mod = String.to_atom(mod)
fun = String.to_atom(fun)

source_file = Server.get_source_file(state, uri)

cur_text = source_file.text

# In case line has changed since this suggestion was generated, look for the function's current
# line number and fall back to the previous line number if we can't guess the new one
line =
if SourceFile.function_def_on_line?(cur_text, line, fun) do
line
else
new_line = SourceFile.function_line(mod, fun, arity)

if SourceFile.function_def_on_line?(cur_text, line, fun) do
new_line
else
raise "Function definition has moved since suggestion was generated. " <>
"Try again after file has been recompiled."
end
end

cur_line = Enum.at(SourceFile.lines(cur_text), line - 1)
[indentation] = Regex.run(Regex.recompile!(~r/^\s*/), cur_line)

# Attempt to format to fit within the preferred line length, fallback to having it all on one
# line if anything fails
formatted =
try do
target_line_length =
case SourceFile.formatter_opts(uri) do
{:ok, opts} -> Keyword.get(opts, :line_length, @default_target_line_length)
:error -> @default_target_line_length
end

target_line_length = target_line_length - String.length(indentation)

Code.format_string!("@spec #{spec}", line_length: target_line_length)
|> IO.iodata_to_binary()
|> SourceFile.lines()
|> Enum.map(&(indentation <> &1))
|> Enum.join("\n")
|> Kernel.<>("\n")
rescue
_ ->
"#{indentation}@spec #{spec}\n"
end

edit_result =
JsonRpc.send_request("workspace/applyEdit", %{
"label" => "Add @spec to #{mod}.#{fun}/#{arity}",
"edit" => %{
"changes" => %{
uri => [%{"range" => range(line - 1, 0, line - 1, 0), "newText" => formatted}]
}
}
})

case edit_result do
{:ok, %{"applied" => true}} ->
{:ok, nil}

other ->
{:error, :server_error,
"cannot insert spec, workspace/applyEdit returned #{inspect(other)}"}
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ExpandMacro do
@moduledoc """
This module implements a custom command expanding an elixir macro.
Returns a formatted source fragment.
"""

alias ElixirLS.LanguageServer.Server

@behaviour ElixirLS.LanguageServer.Providers.ExecuteCommand

@impl ElixirLS.LanguageServer.Providers.ExecuteCommand
def execute("expandMacro", [uri, text, line], state)
when is_binary(text) and is_integer(line) do
source_file = Server.get_source_file(state, uri)
cur_text = source_file.text

if String.trim(text) != "" do
formatted =
ElixirSense.expand_full(cur_text, text, line + 1)
|> Map.new(fn {key, value} ->
key =
key
|> Atom.to_string()
|> Macro.camelize()
|> String.replace("Expand", "expand")

formatted = value |> Code.format_string!() |> List.to_string()
{key, formatted <> "\n"}
end)

{:ok, formatted}
else
# special case to avoid
# warning: invalid expression (). If you want to invoke or define a function, make sure there are
# no spaces between the function name and its arguments. If you wanted to pass an empty block or code,
# pass a value instead, such as a nil or an atom
# nofile:1
{:ok,
%{
"expand" => "\n",
"expandAll" => "\n",
"expandOnce" => "\n",
"expandPartial" => "\n"
}}
end
end
end
12 changes: 11 additions & 1 deletion apps/language_server/lib/language_server/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,12 @@ defmodule ElixirLS.LanguageServer.Server do
end, state}
end

# TODO remove in ElixirLS 0.8
defp handle_request(macro_expansion(_id, whole_buffer, selected_macro, macro_line), state) do
IO.warn(
"Custom `elixirDocument/macroExpansion` request is deprecated. Switch to command `executeMacro` via `workspace/executeCommand`"
)

x = ElixirSense.expand_full(whole_buffer, selected_macro, macro_line)
{:ok, x, state}
end
Expand Down Expand Up @@ -779,7 +784,12 @@ defmodule ElixirLS.LanguageServer.Server do
"workspaceSymbolProvider" => true,
"documentOnTypeFormattingProvider" => %{"firstTriggerCharacter" => "\n"},
"codeLensProvider" => %{"resolveProvider" => false},
"executeCommandProvider" => %{"commands" => ["spec:#{server_instance_id}"]},
"executeCommandProvider" => %{
"commands" => [
"spec:#{server_instance_id}",
"expandMacro"
]
},
"workspace" => %{
"workspaceFolders" => %{"supported" => false, "changeNotifications" => false}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
defmodule ElixirLS.LanguageServer.Providers.ExecuteCommand.ExpandMacroTest do
use ExUnit.Case

alias ElixirLS.LanguageServer.{Server, SourceFile}
alias ElixirLS.LanguageServer.Providers.ExecuteCommand.ExpandMacro

test "nothing to expand" do
uri = "file:///some_file.ex"

text = """
defmodule Abc do
use ElixirLS.Test.MacroA
end
"""

assert {:ok, res} =
ExpandMacro.execute("expandMacro", [uri, "", 1], %Server{
source_files: %{
uri => %SourceFile{
text: text
}
}
})

assert res == %{
"expand" => "\n",
"expandAll" => "\n",
"expandOnce" => "\n",
"expandPartial" => "\n"
}

assert {:ok, res} =
ExpandMacro.execute("expandMacro", [uri, "abc", 1], %Server{
source_files: %{
uri => %SourceFile{
text: text
}
}
})

assert res == %{
"expand" => "abc\n",
"expandAll" => "abc\n",
"expandOnce" => "abc\n",
"expandPartial" => "abc\n"
}
end

test "expands macro" do
uri = "file:///some_file.ex"

text = """
defmodule Abc do
use ElixirLS.Test.MacroA
end
"""

assert {:ok, res} =
ExpandMacro.execute("expandMacro", [uri, "use ElixirLS.Test.MacroA", 1], %Server{
source_files: %{
uri => %SourceFile{
text: text
}
}
})

assert res == %{
"expand" => """
require(ElixirLS.Test.MacroA)
ElixirLS.Test.MacroA.__using__([])
""",
"expandAll" => """
require(ElixirLS.Test.MacroA)

(
import(ElixirLS.Test.MacroA)

def(macro_a_func) do
:ok
end
)
""",
"expandOnce" => """
require(ElixirLS.Test.MacroA)
ElixirLS.Test.MacroA.__using__([])
""",
"expandPartial" => """
require(ElixirLS.Test.MacroA)

(
import(ElixirLS.Test.MacroA)

def(macro_a_func) do
:ok
end
)
"""
}
end
end