diff --git a/apps/language_server/lib/language_server/protocol.ex b/apps/language_server/lib/language_server/protocol.ex index 98b675d99..35601dda5 100644 --- a/apps/language_server/lib/language_server/protocol.ex +++ b/apps/language_server/lib/language_server/protocol.ex @@ -190,6 +190,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..24f815b3a --- /dev/null +++ b/apps/language_server/lib/language_server/providers/rename.ex @@ -0,0 +1,166 @@ +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 + + def rename(%SourceFile{} = source_file, start_uri, line, character, new_name) do + trace = ElixirLS.LanguageServer.Tracer.get_trace() + + edits = + with char_ident when not is_nil(char_ident) <- + get_char_ident(source_file.text, line, character), + %ElixirSense.Location{} = definition <- + ElixirSense.definition(source_file.text, line, character), + references <- ElixirSense.references(source_file.text, line, character, trace) do + length_old = length(char_ident) + + definition_references = + case definition do + %{file: nil, type: :function} -> + parse_definition_source_code(source_file.text) + |> get_all_fn_header_positions(char_ident, definition) + |> positions_to_references(start_uri, length_old) + + %{file: separate_file_path, type: :function} -> + parse_definition_source_code(definition) + |> get_all_fn_header_positions(char_ident, definition) + |> positions_to_references(SourceFile.Path.to_uri(separate_file_path), length_old) + + _ -> + positions_to_references( + [{definition.line, definition.column}], + start_uri, + length_old + ) + end + + Enum.uniq(definition_references ++ repack_references(references, start_uri)) + else + _ -> + [] + end + + changes = + edits + |> Enum.group_by(& &1.uri) + |> Enum.map(fn {uri, edits} -> + %{ + "textDocument" => %{ + "uri" => uri, + "version" => nil + }, + "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}, + char_ident: char_ident + } = res + when not is_nil(res) <- + get_begin_end_and_char_ident(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 function call, skipping + nil + end + + {:ok, result} + end + + defp repack_references(references, start_uri) do + Enum.map(references, fn reference -> + uri = if reference.uri, do: SourceFile.Path.to_uri(reference.uri), else: start_uri + + %{ + 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 parse_definition_source_code(%{file: file}) do + ElixirSense.Core.Parser.parse_file(file, true, true, nil) + end + + defp parse_definition_source_code(source_text) when is_binary(source_text) do + ElixirSense.Core.Parser.parse_string(source_text, true, true, nil) + end + + defp get_all_fn_header_positions( + parsed_source, + definition_name, + %{column: column, line: line} = _definition + ) do + parsed_source.mods_funs_to_positions + |> Map.filter(fn + {{_, fn_name, fn_arity}, %{positions: fn_positions}} -> + Atom.to_charlist(fn_name) === definition_name and not is_nil(fn_arity) and + Enum.member?(fn_positions, {line, column}) + end) + |> Enum.flat_map(fn {_, %{positions: positions}} -> positions end) + |> Enum.uniq() + end + + defp positions_to_references(header_positions, start_uri, length_old) + when is_list(header_positions) do + header_positions + |> Enum.map(fn {line, column} -> + %{ + uri: start_uri, + range: adjust_range(line, column, line, column + length_old) + } + 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 + + defp get_char_ident(text, line, character) do + case get_begin_end_and_char_ident(text, line, character) do + nil -> nil + %{char_ident: char_ident} -> char_ident + end + end + + defp get_begin_end_and_char_ident(text, line, character) do + case Code.Fragment.surround_context(text, {line, character}) do + %{begin: begin, end: the_end, context: {context, char_ident}} + when context in [:local_or_var, :local_call] -> + %{begin: begin, end: the_end, char_ident: char_ident} + + %{begin: begin, end: the_end, context: {:dot, _, char_ident}} -> + %{begin: begin, end: the_end, char_ident: char_ident} + + _ -> + nil + end + end +end diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index a90fae48c..b827fc719 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -36,6 +36,7 @@ defmodule ElixirLS.LanguageServer.Server do Definition, Implementation, References, + Rename, Formatting, SignatureHelp, DocumentSymbols, @@ -1200,6 +1201,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 -> @@ -1310,6 +1331,7 @@ defmodule ElixirLS.LanguageServer.Server do "documentOnTypeFormattingProvider" => %{"firstTriggerCharacter" => "\n"}, "codeLensProvider" => %{"resolveProvider" => false}, "selectionRangeProvider" => true, + "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..62f97f235 --- /dev/null +++ b/apps/language_server/test/providers/rename_test.exs @@ -0,0 +1,376 @@ +defmodule ElixirLS.LanguageServer.Providers.RenameTest do + use ExUnit.Case, async: true + + alias ElixirLS.LanguageServer.Build + alias ElixirLS.LanguageServer.Providers.Rename + alias ElixirLS.LanguageServer.SourceFile + alias ElixirLS.LanguageServer.Test.FixtureHelpers + alias ElixirLS.LanguageServer.Tracer + # mix cmd --app language_server mix test test/providers/rename_test.exs + + @fake_uri "file:///World/Netherlands/Amsterdam/supercomputer/amazing.ex" + + setup_all context do + File.rm_rf!(FixtureHelpers.get_path(".elixir_ls/calls.dets")) + {:ok, pid} = Tracer.start_link([]) + Tracer.set_project_dir(FixtureHelpers.get_path("")) + + compiler_options = Code.compiler_options() + Build.set_compiler_options(ignore_module_conflict: true) + + on_exit(fn -> + Process.monitor(pid) + Process.unlink(pid) + GenServer.stop(pid) + + receive do + {:DOWN, _, _, _, _} -> :ok + end + end) + + Code.compile_file(FixtureHelpers.get_path("rename_example.ex")) + Code.compile_file(FixtureHelpers.get_path("rename_example_b.ex")) + + {:ok, context} + end + + 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} + + source = %SourceFile{text: text, version: 0} + target_range = %{line: 2, start_char: 4, end_char: 5} + + Rename.prepare(source, @fake_uri, line, char) + |> assert_prepare_range_and_placeholder_is(target_range, "a") + + edits = + Rename.rename(source, @fake_uri, line, char, "test") + |> assert_return_structure_and_get_edits(@fake_uri, nil) + + expected_edits = + [ + %{line: 1, start_char: 10, end_char: 11}, + target_range + ] + |> get_expected_edits("test") + + assert sort_edit_by_start_line(edits) == expected_edits + end + + test "nema -> name" do + text = """ + defmodule MyModule do + def hello(nema) do + "Hello " <> nema + end + end + """ + + # "Hello " <> ne_ma + {line, char} = {3, 19} + + source = %SourceFile{text: text, version: 0} + target_range = %{line: 2, start_char: 16, end_char: 20} + + Rename.prepare(source, @fake_uri, line, char) + |> assert_prepare_range_and_placeholder_is(target_range, "nema") + + edits = + Rename.rename( + source, + @fake_uri, + line, + char, + "name" + ) + |> assert_return_structure_and_get_edits(@fake_uri, nil) + + expected_edits = + [ + %{line: 1, start_char: 12, end_char: 16}, + target_range + ] + |> get_expected_edits("name") + + assert sort_edit_by_start_line(edits) == expected_edits + end + + test "renaming a variable definition works original -> new_original" do + text = """ + defmodule MyModule do + def hello do + original = "original" + new = original <> " new stuff" + end + end + """ + + # new = "#{original} + new stuff!" + {line, char} = {3, 6} + source = %SourceFile{text: text, version: 0} + target_range = %{line: 2, start_char: 4, end_char: 12} + + Rename.prepare(source, @fake_uri, line, char) + |> assert_prepare_range_and_placeholder_is(target_range, "original") + + edits = + Rename.rename( + source, + @fake_uri, + line, + char, + "new_original" + ) + |> assert_return_structure_and_get_edits(@fake_uri, nil) + + expected_edits = + [ + target_range, + %{line: 3, start_char: 10, end_char: 18} + ] + |> get_expected_edits("new_original") + + assert sort_edit_by_start_line(edits) == expected_edits + end + end + + describe "renaming local function" do + test "subtract -> new_subtract" do + file_path = FixtureHelpers.get_path("rename_example.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + + # d = subtract(a, b) + {line, char} = {6, 10} + source = %SourceFile{text: text, version: 0} + target_range = %{line: 5, start_char: 8, end_char: 16} + + Rename.prepare(source, @fake_uri, line, char) + |> assert_prepare_range_and_placeholder_is(target_range, "subtract") + + edits = + Rename.rename( + source, + uri, + line, + char, + "new_subtract" + ) + |> assert_return_structure_and_get_edits(uri, nil) + + expected_edits = + [ + target_range, + %{line: 15, start_char: 7, end_char: 15} + ] + |> get_expected_edits("new_subtract") + + assert sort_edit_by_start_line(edits) == expected_edits + end + + test "rename function with multiple heads: add -> new_add" do + file_path = FixtureHelpers.get_path("rename_example.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + + # c = add(a, b) + {line, char} = {5, 9} + source = %SourceFile{text: text, version: 0} + target_range = %{line: 4, start_char: 8, end_char: 11} + + Rename.prepare(source, @fake_uri, line, char) + |> assert_prepare_range_and_placeholder_is(target_range, "add") + + edits = + Rename.rename( + source, + uri, + line, + char, + "new_add" + ) + |> assert_return_structure_and_get_edits(uri, nil) + + expected_edits = + [ + target_range, + %{line: 6, start_char: 4, end_char: 7}, + %{line: 9, start_char: 7, end_char: 10}, + %{line: 10, start_char: 7, end_char: 10}, + %{line: 11, start_char: 7, end_char: 10} + ] + |> get_expected_edits("new_add") + + assert sort_edit_by_start_line(edits) == expected_edits + end + + test "rename function defined in a different file ten -> new_ten" do + file_path = FixtureHelpers.get_path("rename_example.ex") + text = File.read!(file_path) + uri = SourceFile.Path.to_uri(file_path) + + fn_definition_file_uri = + FixtureHelpers.get_path("rename_example_b.ex") |> SourceFile.Path.to_uri() + + # b = ElixirLS.Test.RenameExampleB.ten() + {line, char} = {4, 38} + source = %SourceFile{text: text, version: 0} + + Rename.prepare(source, uri, line, char) + |> assert_prepare_range_and_placeholder_is(%{line: 3, start_char: 8, end_char: 40}, "ten") + + assert {:ok, + %{ + "documentChanges" => [ + %{ + "textDocument" => %{ + "uri" => ^uri, + "version" => nil + }, + "edits" => file_edits + }, + %{ + "textDocument" => %{ + "uri" => ^fn_definition_file_uri, + "version" => nil + }, + "edits" => fn_definition_file_edits + } + ] + }} = + Rename.rename( + source, + uri, + line, + char, + "new_ten" + ) + + expected_edits_fn_definition_file = + [%{line: 1, start_char: 6, end_char: 9}] |> get_expected_edits("new_ten") + + assert sort_edit_by_start_line(fn_definition_file_edits) == + expected_edits_fn_definition_file + + expected_edits = [%{line: 3, start_char: 37, end_char: 40}] |> get_expected_edits("new_ten") + + assert sort_edit_by_start_line(file_edits) == + expected_edits + 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.ex") + 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 + end + + defp get_expected_edits(edits, new_text) when is_list(edits), + do: Enum.map(edits, &get_expected_edits(&1, new_text)) + + defp get_expected_edits(%{line: line, start_char: start_char, end_char: end_char}, new_text) do + %{ + "newText" => new_text, + "range" => %{ + start: %{line: line, character: start_char}, + end: %{line: line, character: end_char} + } + } + end + + defp assert_return_structure_and_get_edits(rename_result, uri, version) do + assert {:ok, + %{ + "documentChanges" => [ + %{ + "textDocument" => %{ + "uri" => ^uri, + "version" => ^version + }, + "edits" => edits + } + ] + }} = rename_result + + edits + end + + defp sort_edit_by_start_line(edits) do + Enum.sort(edits, &(&1["range"].start.line < &2["range"].start.line)) + end + + defp assert_prepare_range_and_placeholder_is( + prepare_result, + %{line: line, start_char: start_char, end_char: end_char} = _expected_range, + expected_placeholder + ) do + assert {:ok, + %{ + placeholder: expected_placeholder, + range: %{ + start: %{line: line, character: start_char}, + end: %{line: line, character: end_char} + } + }} == prepare_result + end +end diff --git a/apps/language_server/test/support/fixtures/rename_example.ex b/apps/language_server/test/support/fixtures/rename_example.ex new file mode 100644 index 000000000..2ee17656f --- /dev/null +++ b/apps/language_server/test/support/fixtures/rename_example.ex @@ -0,0 +1,17 @@ +defmodule ElixirLS.Test.RenameExample do + def main do + a = 5 + b = ElixirLS.Test.RenameExampleB.ten() + c = add(a, b) + d = subtract(a, b) + add(c, d) + end + + defp add(a, b) + defp add(a, b) when is_integer(a) and is_integer(b), do: a + b + defp add(a, b) when is_binary(a) and is_binary(b), do: a <> b + + def add(a, b, c), do: a + b + c + + defp subtract(a, b), do: a - b +end diff --git a/apps/language_server/test/support/fixtures/rename_example_b.ex b/apps/language_server/test/support/fixtures/rename_example_b.ex new file mode 100644 index 000000000..06839db1d --- /dev/null +++ b/apps/language_server/test/support/fixtures/rename_example_b.ex @@ -0,0 +1,3 @@ +defmodule ElixirLS.Test.RenameExampleB do + def ten, do: 10 +end