diff --git a/apps/language_server/lib/language_server/protocol.ex b/apps/language_server/lib/language_server/protocol.ex index a61ed23f5..b5864f652 100644 --- a/apps/language_server/lib/language_server/protocol.ex +++ b/apps/language_server/lib/language_server/protocol.ex @@ -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", %{ diff --git a/apps/language_server/lib/language_server/providers/execute_command.ex b/apps/language_server/lib/language_server/providers/execute_command.ex index 7d69dd411..f87eb7832 100644 --- a/apps/language_server/lib/language_server/providers/execute_command.ex +++ b/apps/language_server/lib/language_server/providers/execute_command.ex @@ -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 diff --git a/apps/language_server/lib/language_server/providers/execute_command/apply_spec.ex b/apps/language_server/lib/language_server/providers/execute_command/apply_spec.ex new file mode 100644 index 000000000..ef49ac73c --- /dev/null +++ b/apps/language_server/lib/language_server/providers/execute_command/apply_spec.ex @@ -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 diff --git a/apps/language_server/lib/language_server/providers/execute_command/expand_macro.ex b/apps/language_server/lib/language_server/providers/execute_command/expand_macro.ex new file mode 100644 index 000000000..dc8897e5e --- /dev/null +++ b/apps/language_server/lib/language_server/providers/execute_command/expand_macro.ex @@ -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 diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index 5a296251a..bc4aa62fe 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -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 @@ -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} } diff --git a/apps/language_server/test/providers/execute_command/expand_macro_test.exs b/apps/language_server/test/providers/execute_command/expand_macro_test.exs new file mode 100644 index 000000000..bb24274f9 --- /dev/null +++ b/apps/language_server/test/providers/execute_command/expand_macro_test.exs @@ -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