diff --git a/apps/language_server/lib/language_server/protocol.ex b/apps/language_server/lib/language_server/protocol.ex index faf5af976..044f496d0 100644 --- a/apps/language_server/lib/language_server/protocol.ex +++ b/apps/language_server/lib/language_server/protocol.ex @@ -181,6 +181,25 @@ defmodule ElixirLS.LanguageServer.Protocol do end end + defmacro rename_req(id, uri, line, character, new_name) do + quote do + request(unquote(id), "textDocument/rename", %{ + "textDocument" => %{"uri" => unquote(uri)}, + "position" => %{"line" => unquote(line), "character" => unquote(character)}, + "newName" => unquote(new_name) + }) + end + end + + defmacro prepare_rename_req(id, uri, line, character) do + quote do + request(unquote(id), "textDocument/prepareRename", %{ + "textDocument" => %{"uri" => unquote(uri)}, + "position" => %{"line" => unquote(line), "character" => unquote(character)} + }) + end + end + defmacro execute_command_req(id, command, arguments) do quote do request(unquote(id), "workspace/executeCommand", %{ diff --git a/apps/language_server/lib/language_server/providers/rename.ex b/apps/language_server/lib/language_server/providers/rename.ex new file mode 100644 index 000000000..2c31af2bb --- /dev/null +++ b/apps/language_server/lib/language_server/providers/rename.ex @@ -0,0 +1,100 @@ +defmodule ElixirLS.LanguageServer.Providers.Rename do + @moduledoc """ + Provides functionality to rename a symbol inside a workspace + + https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rename + """ + + alias ElixirLS.LanguageServer.SourceFile + #import ElixirLS.LanguageServer.Protocol + + def rename(%SourceFile{} = source_file, start_uri, line, character, new_name) do + edits = + with %{context: {context, char_ident}} when context in [:local_or_var, :local_call] <- + Code.Fragment.surround_context(source_file.text, {line, character}), + %ElixirSense.Location{} = definition <- + ElixirSense.definition(source_file.text, line, character), + references <- ElixirSense.references(source_file.text, line, character) do + + + length_old = length(char_ident) + + [ + %{ + uri: start_uri, + range: + adjust_range( + definition.line, + definition.column, + definition.line, + definition.column + length_old + ) + } + | repack_references(references, start_uri) + ] + else + _ -> + [] + end + + changes = + edits + |> Enum.group_by(& &1.uri) + |> Enum.map(fn {uri, edits} -> + %{ + "textDocument" => %{ + "uri" => uri, + "version" => source_file.version + 1 + }, + "edits" => + Enum.map(edits, fn edit -> + %{"range" => edit.range, "newText" => new_name} + end) + } + end) + + {:ok, %{"documentChanges" => changes}} + end + + def prepare(%SourceFile{} = source_file, _uri, line, character) do + result = + with %{ + begin: {start_line, start_col}, + end: {end_line, end_col}, + context: {context, char_ident} + } when context in [:local_or_var, :local_call] <- Code.Fragment.surround_context(source_file.text, {line, character}) do + %{ + range: adjust_range(start_line, start_col, end_line, end_col), + placeholder: to_string(char_ident) + } + else + _ -> + # Not a variable or local call, skipping for now + nil + end + + {:ok, result} + end + + defp repack_references(references, uri) do + for reference <- references do + %{ + uri: uri, + range: %{ + end: %{character: reference.range.end.column - 1, line: reference.range.end.line - 1}, + start: %{ + character: reference.range.start.column - 1, + line: reference.range.start.line - 1 + } + } + } + end + end + + defp adjust_range(start_line, start_character, end_line, end_character) do + %{ + start: %{line: start_line - 1, character: start_character - 1}, + end: %{line: end_line - 1, character: end_character - 1} + } + end +end diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index 0f2d4c09b..4e16c0920 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -24,6 +24,7 @@ defmodule ElixirLS.LanguageServer.Server do Definition, Implementation, References, + Rename, Formatting, SignatureHelp, DocumentSymbols, @@ -739,6 +740,26 @@ defmodule ElixirLS.LanguageServer.Server do {:async, fun, state} end + defp handle_request(rename_req(_id, uri, line, character, new_name), state = %__MODULE__{}) do + source_file = get_source_file(state, uri) + + fun = fn -> + Rename.rename(source_file, uri, line + 1, character + 1, new_name) + end + + {:async, fun, state} + end + + defp handle_request(prepare_rename_req(_id, uri, line, character), state = %__MODULE__{}) do + source_file = get_source_file(state, uri) + + fun = fn -> + Rename.prepare(source_file, uri, line + 1, character + 1) + end + + {:async, fun, state} + end + defp handle_request(execute_command_req(_id, command, args) = req, state = %__MODULE__{}) do {:async, fn -> @@ -809,6 +830,7 @@ defmodule ElixirLS.LanguageServer.Server do "workspaceSymbolProvider" => true, "documentOnTypeFormattingProvider" => %{"firstTriggerCharacter" => "\n"}, "codeLensProvider" => %{"resolveProvider" => false}, + "renameProvider" => %{"prepareProvider" => true}, "executeCommandProvider" => %{ "commands" => ExecuteCommand.get_commands(server_instance_id) }, diff --git a/apps/language_server/test/providers/rename_test.exs b/apps/language_server/test/providers/rename_test.exs new file mode 100644 index 000000000..ad3793c5b --- /dev/null +++ b/apps/language_server/test/providers/rename_test.exs @@ -0,0 +1,213 @@ +defmodule ElixirLS.LanguageServer.Providers.RenameTest do + use ExUnit.Case, async: true + + alias ElixirLS.LanguageServer.Providers.Rename + alias ElixirLS.LanguageServer.SourceFile + alias ElixirLS.LanguageServer.Test.FixtureHelpers + # mix cmd --app language_server mix test test/providers/rename_test.exs + + @fake_uri "file:///World/Netherlands/Amsterdam/supercomputer/amazing.ex" + + test "rename blank space" do + text = """ + defmodule MyModule do + def hello() do + IO.inspect("hello world") + end + end + """ + + {line, char} = {2, 1} + + assert {:ok, %{"documentChanges" => []}} = + Rename.rename(%SourceFile{text: text, version: 0}, @fake_uri, line, char, "test") + end + + describe "renaming variable" do + test "a -> test" do + text = """ + defmodule MyModule do + def add(a, b) do + a + b + end + end + """ + + # _a + b + {line, char} = {3, 5} + + assert {:ok, %{"documentChanges" => changes}} = + Rename.rename(%SourceFile{text: text, version: 0}, @fake_uri, line, char, "test") + + assert %{ + "textDocument" => %{ + "uri" => @fake_uri, + "version" => 1 + }, + "edits" => [ + %{ + "range" => %{end: %{character: 11, line: 1}, start: %{character: 10, line: 1}}, + "newText" => "test" + }, + %{ + "range" => %{end: %{character: 5, line: 2}, start: %{character: 4, line: 2}}, + "newText" => "test" + } + ] + } == List.first(changes) + end + + test "nema -> name" do + text = """ + defmodule MyModule do + def hello(nema) do + "Hello " <> nema + end + end + """ + + # "Hello " <> ne_ma + {line, char} = {3, 19} + + assert {:ok, %{"documentChanges" => [changes]}} = + Rename.rename( + %SourceFile{text: text, version: 0}, + @fake_uri, + line, + char, + "name" + ) + + assert %{ + "textDocument" => %{ + "uri" => @fake_uri, + "version" => 1 + }, + "edits" => [ + %{ + "range" => %{end: %{character: 16, line: 1}, start: %{character: 12, line: 1}}, + "newText" => "name" + }, + %{ + "range" => %{end: %{character: 20, line: 2}, start: %{character: 16, line: 2}}, + "newText" => "name" + } + ] + } == changes + end + end + + describe "renaming local function" do + test "create_message -> store_message" do + file_path = FixtureHelpers.get_path("rename_example.exs") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + + # |> _create_message + {line, char} = {28, 8} + + assert {:ok, %{"documentChanges" => [changes]}} = + Rename.rename( + %SourceFile{text: text, version: 0}, + uri, + line, + char, + "store_message" + ) + + assert %{ + "textDocument" => %{ + "uri" => uri, + "version" => 1 + }, + "edits" => [ + %{ + "newText" => "store_message", + "range" => %{end: %{character: 21, line: 43}, start: %{character: 7, line: 43}} + }, + %{ + "newText" => "store_message", + "range" => %{end: %{character: 21, line: 27}, start: %{character: 7, line: 27}} + } + ] + } == changes + end + end + + describe "not yet (fully) supported/working renaming cases" do + test "rename started with cursor at function definition" do + file_path = FixtureHelpers.get_path("rename_example.exs") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + + # defp _handle_error({:ok, message}) + {line, char} = {4, 8} + + assert {:ok, %{"documentChanges" => changes}} = + Rename.rename( + %SourceFile{text: text, version: 0}, + uri, + line, + char, + "handle_errors" + ) + + refute %{ + "textDocument" => %{ + "uri" => uri, + "version" => 1 + }, + "edits" => [ + %{ + "newText" => "handle_errors", + "range" => %{end: %{character: 19, line: 37}, start: %{character: 7, line: 37}} + }, + %{ + "newText" => "handle_errors", + "range" => %{end: %{character: 19, line: 39}, start: %{character: 7, line: 39}} + }, + %{ + "newText" => "handle_errors", + "range" => %{end: %{character: 19, line: 28}, start: %{character: 7, line: 28}} + } + ] + } == List.first(changes) + end + + test "rename function with multiple heads" do + file_path = FixtureHelpers.get_path("rename_example.exs") + text = File.read!(file_path) + uri = SourceFile.path_to_uri(file_path) + + # |> _handle_error + {line, char} = {29, 8} + + assert {:ok, %{"documentChanges" => [changes]}} = + Rename.rename( + %SourceFile{text: text, version: 0}, + uri, + line, + char, + "handle_errors" + ) + + # missed second function head on line 40: handle_error({:error, changeset}) + assert %{ + "textDocument" => %{ + "uri" => uri, + "version" => 1 + }, + "edits" => [ + %{ + "newText" => "handle_errors", + "range" => %{end: %{character: 19, line: 37}, start: %{character: 7, line: 37}} + }, + %{ + "newText" => "handle_errors", + "range" => %{end: %{character: 19, line: 28}, start: %{character: 7, line: 28}} + } + ] + } == changes + end + end +end diff --git a/apps/language_server/test/support/fixtures/rename_example.exs b/apps/language_server/test/support/fixtures/rename_example.exs new file mode 100644 index 000000000..35c3a57be --- /dev/null +++ b/apps/language_server/test/support/fixtures/rename_example.exs @@ -0,0 +1,67 @@ +defmodule Example.Pipeline do + use Broadway + + alias Broadway.Message + + def start_link(_) do + {module, opts} = Application.get_env(:broadway_sqs, :producer_module) + + Broadway.start_link(__MODULE__, + name: __MODULE__, + producer: [ + module: {module, opts} + ], + processors: [ + default: [] + ], + batchers: [ + default: [ + batch_size: 10, + batch_timeout: 2000 + ] + ] + ) + end + + def handle_message(_, %Message{data: data} = message, _) do + info + |> create_message + |> handle_error + + message + end + + def handle_batch(_, messages, _, _) do + messages + end + + defp handle_error({:ok, message}), do: message + + defp handle_error({:error, changeset}) do + raise Example.SqsPipelineException, message: changeset.errors + end + + defp create_message(data) do + data + |> Jason.decode(save_message) + |> prepare_params + |> save_message + end + + defp save_message({:ok, params}) do + %Collector.Message{} + |> Collector.Message.changeset(params) + |> Collector.Repo.insert() + end + + defp prepare_params({:ok, %{"action" => _, "payload" => _}} = params), + do: params + + defp prepare_params(_) do + raise Example.PipelineException, message: "Message does not contain all necessary keys." + end +end + +defmodule Example.PipelineException do + defexception [:message] +end \ No newline at end of file