Skip to content

Commit

Permalink
Add expandMacro custom command (#498)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
3 people authored Feb 28, 2021
1 parent e1538ed commit af7ba81
Show file tree
Hide file tree
Showing 6 changed files with 268 additions and 87 deletions.
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

0 comments on commit af7ba81

Please sign in to comment.