From 5bc85ac64b641a56f97233223a14d834abb4364a Mon Sep 17 00:00:00 2001 From: Scott Ming Date: Sun, 3 Mar 2024 13:11:59 +0800 Subject: [PATCH] Introduced rename functionality across modules Enhanced the application by integrating rename functionality, increasing its editing capabilities. This includes supporting the preparation and execution of rename operations in various modules such as the lexical AST module, protocol request and response modules, remote control API, and server provider handling. Key additions entail aliasing for easier reference, handling new rename-related requests and responses, and implementing server state updates to acknowledge rename capabilities. The changes underscore our dedication to improving the tool's utility and ensuring a more dynamic and responsive user experience in code manipulation scenarios. Filter out the `:struct` type references Add some handler tests for `rename` Add doc for `local_module_name` Move `rename` from `code_intelligence` to `code_mod` Move the prepare logic to a individual module This PR is primarily copied from #374, but compared to the previous implementation, we have made two changes: 1. We constrained the scope of renaming, meaning that renaming a module is only allowed at its definition. This simplification reduces a lot of computations. 2. Based on the first point, our preparation no longer returns just a single local module, but instead, the entire module. This means that module renaming has become more powerful. We can not only rename a single local module but also simplify a module. For example, renaming TopLevel.Parent.Child to TopLevel.Child, or renaming some middle parts, like TopLevel.Parent.Child to TopLevel.Renamed. I personally really like this change, especially the second point, which makes module renaming much more practical. Surround the whole module when renaming happens Remove logic related to the rename function. Apply some code review suggestions Fix a bug when expanding the module Fix a merge error Apply a format module usage suggestion Update apps/remote_control/lib/lexical/remote_control/code_mod/rename.ex Co-authored-by: Steve Cohen Apply the `defdelegate` suggestion Fix a bug when the descendant containing the old suffix --- .../protocol/types/prepare_rename/params.ex | 10 + .../protocol/types/prepare_rename_result.ex | 23 ++ .../lexical/protocol/types/rename/params.ex | 11 + .../protocol/lib/lexical/protocol/requests.ex | 12 + .../lib/lexical/protocol/responses.ex | 12 + .../lexical/remote_control/code_mod/rename.ex | 22 + .../remote_control/code_mod/rename/module.ex | 185 +++++++++ .../remote_control/code_mod/rename/prepare.ex | 87 ++++ .../remote_control/code_mod/rename_test.exs | 388 ++++++++++++++++++ apps/server/lib/lexical/server.ex | 6 + .../provider/handlers/prepare_rename.ex | 40 ++ .../server/provider/handlers/rename.ex | 39 ++ apps/server/lib/lexical/server/state.ex | 4 +- .../server/provider/handlers/rename_test.exs | 106 +++++ 14 files changed, 944 insertions(+), 1 deletion(-) create mode 100644 apps/protocol/lib/generated/lexical/protocol/types/prepare_rename/params.ex create mode 100644 apps/protocol/lib/generated/lexical/protocol/types/prepare_rename_result.ex create mode 100644 apps/protocol/lib/generated/lexical/protocol/types/rename/params.ex create mode 100644 apps/remote_control/lib/lexical/remote_control/code_mod/rename.ex create mode 100644 apps/remote_control/lib/lexical/remote_control/code_mod/rename/module.ex create mode 100644 apps/remote_control/lib/lexical/remote_control/code_mod/rename/prepare.ex create mode 100644 apps/remote_control/test/lexical/remote_control/code_mod/rename_test.exs create mode 100644 apps/server/lib/lexical/server/provider/handlers/prepare_rename.ex create mode 100644 apps/server/lib/lexical/server/provider/handlers/rename.ex create mode 100644 apps/server/test/lexical/server/provider/handlers/rename_test.exs diff --git a/apps/protocol/lib/generated/lexical/protocol/types/prepare_rename/params.ex b/apps/protocol/lib/generated/lexical/protocol/types/prepare_rename/params.ex new file mode 100644 index 000000000..586807a3f --- /dev/null +++ b/apps/protocol/lib/generated/lexical/protocol/types/prepare_rename/params.ex @@ -0,0 +1,10 @@ +# This file's contents are auto-generated. Do not edit. +defmodule Lexical.Protocol.Types.PrepareRename.Params do + alias Lexical.Proto + alias Lexical.Protocol.Types + use Proto + + deftype position: Types.Position, + text_document: Types.TextDocument.Identifier, + work_done_token: optional(Types.Progress.Token) +end diff --git a/apps/protocol/lib/generated/lexical/protocol/types/prepare_rename_result.ex b/apps/protocol/lib/generated/lexical/protocol/types/prepare_rename_result.ex new file mode 100644 index 000000000..424508fb8 --- /dev/null +++ b/apps/protocol/lib/generated/lexical/protocol/types/prepare_rename_result.ex @@ -0,0 +1,23 @@ +# This file's contents are auto-generated. Do not edit. +defmodule Lexical.Protocol.Types.PrepareRenameResult do + alias Lexical.Proto + alias Lexical.Protocol.Types + + defmodule PrepareRenameResult do + use Proto + deftype placeholder: string(), range: Types.Range + end + + defmodule PrepareRenameResult1 do + use Proto + deftype default_behavior: boolean() + end + + use Proto + + defalias one_of([ + Types.Range, + Lexical.Protocol.Types.PrepareRenameResult.PrepareRenameResult, + Lexical.Protocol.Types.PrepareRenameResult.PrepareRenameResult1 + ]) +end diff --git a/apps/protocol/lib/generated/lexical/protocol/types/rename/params.ex b/apps/protocol/lib/generated/lexical/protocol/types/rename/params.ex new file mode 100644 index 000000000..b085aa519 --- /dev/null +++ b/apps/protocol/lib/generated/lexical/protocol/types/rename/params.ex @@ -0,0 +1,11 @@ +# This file's contents are auto-generated. Do not edit. +defmodule Lexical.Protocol.Types.Rename.Params do + alias Lexical.Proto + alias Lexical.Protocol.Types + use Proto + + deftype new_name: string(), + position: Types.Position, + text_document: Types.TextDocument.Identifier, + work_done_token: optional(Types.Progress.Token) +end diff --git a/apps/protocol/lib/lexical/protocol/requests.ex b/apps/protocol/lib/lexical/protocol/requests.ex index 29ac9f721..34a44a564 100644 --- a/apps/protocol/lib/lexical/protocol/requests.ex +++ b/apps/protocol/lib/lexical/protocol/requests.ex @@ -88,6 +88,18 @@ defmodule Lexical.Protocol.Requests do defrequest "workspace/symbol", Types.Workspace.Symbol.Params end + defmodule PrepareRename do + use Proto + + defrequest "textDocument/prepareRename", Types.PrepareRename.Params + end + + defmodule Rename do + use Proto + + defrequest "textDocument/rename", Types.Rename.Params + end + # Server -> Client requests defmodule RegisterCapability do diff --git a/apps/protocol/lib/lexical/protocol/responses.ex b/apps/protocol/lib/lexical/protocol/responses.ex index 847805dd9..540731ad1 100644 --- a/apps/protocol/lib/lexical/protocol/responses.ex +++ b/apps/protocol/lib/lexical/protocol/responses.ex @@ -87,5 +87,17 @@ defmodule Lexical.Protocol.Responses do defresponse optional(Types.Message.ActionItem) end + defmodule PrepareRename do + use Proto + + defresponse Types.PrepareRenameResult + end + + defmodule Rename do + use Proto + + defresponse optional(Types.Workspace.Edit) + end + use Typespecs, for: :responses end diff --git a/apps/remote_control/lib/lexical/remote_control/code_mod/rename.ex b/apps/remote_control/lib/lexical/remote_control/code_mod/rename.ex new file mode 100644 index 000000000..506f199c7 --- /dev/null +++ b/apps/remote_control/lib/lexical/remote_control/code_mod/rename.ex @@ -0,0 +1,22 @@ +defmodule Lexical.RemoteControl.CodeMod.Rename do + alias Lexical.Ast.Analysis + alias Lexical.Document.Edit + alias Lexical.Document.Position + alias Lexical.Document.Range + alias __MODULE__ + + @spec prepare(Analysis.t(), Position.t()) :: + {:ok, {atom(), String.t()}, Range.t()} | {:error, term()} + defdelegate prepare(analysis, position), to: Rename.Prepare + + @rename_mapping %{module: Rename.Module} + + @spec rename(Analysis.t(), Position.t(), String.t()) :: + {:ok, %{Lexical.uri() => [Edit.t()]}} | {:error, term()} + def rename(%Analysis{} = analysis, %Position{} = position, new_name) do + with {:ok, {renamable, entity}, range} <- Rename.Prepare.resolve(analysis, position) do + rename_module = @rename_mapping[renamable] + {:ok, rename_module.rename(range, new_name, entity)} + end + end +end diff --git a/apps/remote_control/lib/lexical/remote_control/code_mod/rename/module.ex b/apps/remote_control/lib/lexical/remote_control/code_mod/rename/module.ex new file mode 100644 index 000000000..4671d177b --- /dev/null +++ b/apps/remote_control/lib/lexical/remote_control/code_mod/rename/module.ex @@ -0,0 +1,185 @@ +defmodule Lexical.RemoteControl.CodeMod.Rename.Module do + alias Lexical.Ast.Analysis + alias Lexical.Document + alias Lexical.Document.Edit + alias Lexical.Document.Line + alias Lexical.Document.Position + alias Lexical.Document.Range + alias Lexical.RemoteControl.CodeIntelligence.Entity + alias Lexical.RemoteControl.Search.Store + require Logger + + import Line + + @spec rename(Range.t(), String.t(), atom()) :: %{Lexical.uri() => [Edit.t()]} + def rename(%Range{} = old_range, new_name, entity) do + {old_suffix, new_suffix} = old_range |> range_text() |> diff(new_name) + results = exacts(entity, old_suffix) ++ descendants(entity, old_suffix) + + Enum.group_by( + results, + &Document.Path.ensure_uri(&1.path), + &Edit.new(new_suffix, &1.range) + ) + end + + @spec resolve(Analysis.t() | Lexical.path(), Position.t()) :: + {:ok, {atom(), atom()}, Range.t()} | {:error, term()} + def resolve(%Analysis{} = analysis, %Position{} = position) do + case Entity.resolve(analysis, position) do + {:ok, {module_or_struct, module}, range} when module_or_struct in [:struct, :module] -> + {:ok, {:module, module}, range} + + _ -> + {:error, :not_a_module} + end + end + + def resolve(path, %Position{} = position) do + uri = Document.Path.ensure_uri(path) + + with {:ok, _} <- Document.Store.open_temporary(uri), + {:ok, _document, analysis} <- Document.Store.fetch(uri, :analysis) do + resolve(analysis, position) + end + end + + defp diff(old_range_text, new_name) do + diff = String.myers_difference(old_range_text, new_name) + + eq = + if match?([{:eq, _eq} | _], diff) do + diff |> hd() |> elem(1) + else + "" + end + + old_suffix = String.replace(old_range_text, ~r"^#{eq}", "") + new_suffix = String.replace(new_name, ~r"^#{eq}", "") + {old_suffix, new_suffix} + end + + defp exacts(entity, old_suffix) do + entity + |> query_for_exacts() + |> Enum.filter(&entry_matching?(&1, old_suffix)) + |> adjust_range_for_exacts(old_suffix) + end + + defp descendants(entity, old_suffix) do + entity + |> query_for_descendants() + |> Enum.filter(&(entry_matching?(&1, old_suffix) and has_dots_in_range?(&1))) + |> adjust_range_for_descendants(entity, old_suffix) + end + + defp query_for_exacts(entity) do + entity_string = inspect(entity) + + case Store.exact(entity_string, type: :module) do + {:ok, entries} -> entries + {:error, _} -> [] + end + end + + defp query_for_descendants(entity) do + prefix = "#{inspect(entity)}." + + case Store.prefix(prefix, type: :module) do + {:ok, entries} -> entries + {:error, _} -> [] + end + end + + defp maybe_rename_file(document, entries, new_suffix) do + entries + |> Enum.map(&Rename.File.maybe_rename(document, &1, new_suffix)) + # every group should have only one `rename_file` + |> Enum.find(&(not is_nil(&1))) + end + + defp entry_matching?(entry, old_suffix) do + entry.range |> range_text() |> String.contains?(old_suffix) + end + + defp has_dots_in_range?(entry) do + entry.range |> range_text() |> String.contains?(".") + end + + defp adjust_range_for_exacts(entries, old_suffix) do + for entry <- entries do + start_character = entry.range.end.character - String.length(old_suffix) + start_position = %{entry.range.start | character: start_character} + range = %{entry.range | start: start_position} + %{entry | range: range} + end + end + + defp adjust_range_for_descendants(entries, entity, old_suffix) do + for entry <- entries, + range_text = range_text(entry.range), + matches = matches(range_text, old_suffix), + result = resolve_module_range(entry, entity, matches), + match?({:ok, _}, result) do + {_, range} = result + %{entry | range: range} + end + end + + defp range_text(range) do + line(text: text) = range.end.context_line + String.slice(text, range.start.character - 1, range.end.character - range.start.character) + end + + defp resolve_module_range(_entry, _entity, []) do + {:error, :not_found} + end + + defp resolve_module_range(entry, entity, [[{start, length}]]) do + range = adjust_range_characters(entry.range, {start, length}) + + with {:ok, {:module, result}, _} <- resolve(entry.path, range.start), + true <- entity == result do + {:ok, range} + end + end + + defp resolve_module_range(entry, entity, [[{start, length}] | tail] = _matches) do + # This function is mainly for the duplicated suffixes + # For example, if we have a module named `Foo.Bar.Foo.Bar` and we want to rename it to `Foo.Bar.Baz` + # The `Foo.Bar` will be duplicated in the range text, so we need to resolve the correct range + # and only rename the second occurrence of `Foo.Bar` + start_character = entry.range.start.character + start + position = %{entry.range.start | character: start_character} + + with {:ok, {:module, result}, range} <- resolve(entry.path, position) do + if result == entity do + range = adjust_range_characters(range, {start, length}) + {:ok, range} + else + resolve_module_range(entry, entity, tail) + end + end + end + + defp matches(range_text, "") do + # When expanding a module, the old_suffix is an empty string, + # so we need to scan the module before the period + for [{start, length}] <- Regex.scan(~r/\w+(?=\.)/, range_text, return: :index) do + [{start + length, 0}] + end + end + + defp matches(range_text, old_suffix) do + Regex.scan(~r/#{old_suffix}/, range_text, return: :index) + end + + defp adjust_range_characters(%Range{} = range, {start, length} = _matched_old_suffix) do + start_character = range.start.character + start + end_character = start_character + length + + range + |> put_in([:start, :character], start_character) + |> put_in([:end, :character], end_character) + end +end diff --git a/apps/remote_control/lib/lexical/remote_control/code_mod/rename/prepare.ex b/apps/remote_control/lib/lexical/remote_control/code_mod/rename/prepare.ex new file mode 100644 index 000000000..9810e0451 --- /dev/null +++ b/apps/remote_control/lib/lexical/remote_control/code_mod/rename/prepare.ex @@ -0,0 +1,87 @@ +defmodule Lexical.RemoteControl.CodeMod.Rename.Prepare do + alias Lexical.Ast + alias Lexical.Ast.Analysis + alias Lexical.Document.Position + alias Lexical.Document.Range + alias Lexical.Formats + alias Lexical.RemoteControl.CodeIntelligence.Entity + alias Lexical.RemoteControl.CodeMod.Rename + alias Lexical.RemoteControl.Search.Store + + require Logger + + @spec prepare(Analysis.t(), Position.t()) :: + {:ok, {atom(), String.t()}, Range.t()} | {:error, term()} + def prepare(%Analysis{} = analysis, %Position{} = position) do + case resolve(analysis, position) do + {:ok, {:module, module}, range} -> + {:ok, Formats.module(module), range} + + {:error, error} -> + {:error, error} + end + end + + @spec resolve(Analysis.t(), Position.t()) :: + {:ok, {atom(), atom()} | {atom(), tuple()}, Range.t()} | {:error, term()} + def resolve(%Analysis{} = analysis, %Position{} = position) do + case do_resolve(analysis, position) do + {:ok, {:module, _module}, _range} -> + {module, range} = surround_the_whole_module(analysis, position) + + if cursor_at_declaration?(module, range) do + {:ok, {:module, module}, range} + else + {:error, {:unsupported_location, :module}} + end + + other -> + other + end + end + + defp surround_the_whole_module(analysis, position) do + # When renaming occurs, we want users to be able to choose any place in the defining module, + # not just the last local module, like: `defmodule |Foo.Bar do` also works. + {:ok, %{end: {_end_line, end_character}}} = Ast.surround_context(analysis, position) + end_position = %{position | character: end_character - 1} + {:ok, {:module, module}, range} = do_resolve(analysis, end_position) + {module, range} + end + + defp cursor_at_declaration?(module, rename_range) do + case Store.exact(module, type: :module, subtype: :definition) do + {:ok, [definition]} -> + rename_range == definition.range + + _ -> + false + end + end + + @renamable_modules [Rename.Module] + + defp do_resolve(%Analysis{} = analysis, %Position{} = position) do + result = + Enum.find_value(@renamable_modules, fn module -> + result = module.resolve(analysis, position) + + if match?({:ok, _, _}, result) do + result + end + end) + + if is_nil(result) do + case Entity.resolve(analysis, position) do + {:ok, other, _} -> + Logger.info("Unsupported entity for renaming: #{inspect(other)}") + {:error, :unsupported_entity} + + {:error, reason} -> + {:error, reason} + end + else + result + end + end +end diff --git a/apps/remote_control/test/lexical/remote_control/code_mod/rename_test.exs b/apps/remote_control/test/lexical/remote_control/code_mod/rename_test.exs new file mode 100644 index 000000000..98af234d0 --- /dev/null +++ b/apps/remote_control/test/lexical/remote_control/code_mod/rename_test.exs @@ -0,0 +1,388 @@ +defmodule Lexical.RemoteControl.CodeMod.RenameTest do + alias Lexical.Document + alias Lexical.Project + alias Lexical.RemoteControl + alias Lexical.RemoteControl.CodeMod.Rename + alias Lexical.RemoteControl.Search + alias Lexical.RemoteControl.Search.Store.Backends + alias Lexical.Test.CodeSigil + alias Lexical.Test.CursorSupport + alias Lexical.Test.Fixtures + + import CodeSigil + import CursorSupport + import Lexical.Test.EventualAssertions + import Fixtures + + use ExUnit.Case + + setup_all do + project = project() + + Backends.Ets.destroy_all(project) + RemoteControl.set_project(project) + + start_supervised!({Document.Store, derive: [analysis: &Lexical.Ast.analyze/1]}) + start_supervised!(RemoteControl.Dispatch) + start_supervised!(Backends.Ets) + + start_supervised!( + {Search.Store, [project, fn _ -> {:ok, []} end, fn _, _ -> {:ok, [], []} end, Backends.Ets]} + ) + + Search.Store.enable() + assert_eventually Search.Store.loaded?(), 1500 + + on_exit(fn -> + Backends.Ets.destroy_all(project) + end) + + {:ok, project: project} + end + + setup %{project: project} do + uri = subject_uri(project) + + on_exit(fn -> + Document.Store.close(uri) + end) + + %{uri: uri} + end + + describe "prepare/2" do + test "returns the module name" do + {:ok, result, _} = + ~q[ + defmodule |Foo do + end + ] |> prepare() + + assert result == "Foo" + end + + test "returns the whole module name" do + {:ok, result, _} = + ~q[ + defmodule TopLevel.|Foo do + end + ] |> prepare() + + assert result == "TopLevel.Foo" + end + + test "returns the whole module name even if the cusor is not at the end" do + {:ok, result, _} = + ~q[ + defmodule Top|Level.Foo do + end + ] |> prepare() + + assert result == "TopLevel.Foo" + end + + test "returns location error when renaming a module occurs in a reference." do + assert {:error, {:unsupported_location, :module}} == + ~q[ + defmodule Foo do + end + + defmodule Bar do + alias |Foo + end + ] |> prepare() + end + + test "returns error when the entity is not found" do + assert {:error, :unsupported_entity} = + ~q[ + x = 1 + |x + ] |> prepare() + end + end + + describe "rename exact module" do + test "succeeds when the cursor is at the definition" do + {:ok, result} = + ~q[ + defmodule |Foo do + end + ] |> rename("Renamed") + + assert result =~ ~S[defmodule Renamed do] + end + + test "failed when the cursor is at the alias" do + assert {:error, {:unsupported_location, :module}} == + ~q[ + defmodule Baz do + alias |Foo + end + ] |> rename("Renamed") + end + + test "failed when the cursor is not at reference" do + assert {:error, {:unsupported_location, :module}} == + ~q[ + defmodule TopLevel.Context do + alias TopLevel.Baz + Ba|z.foo() + end + ] |> rename("Renamed") + end + + test "succeeds when the module has multiple dots" do + {:ok, result} = ~q[ + defmodule TopLevel.Foo.|Bar do + end + ] |> rename("TopLevel.Foo.Renamed") + + assert result =~ ~S[defmodule TopLevel.Foo.Renamed do] + end + + test "succeeds when renaming the middle part of the module" do + {:ok, result} = + ~q[ + defmodule TopLevel.Foo.|Bar do + end + ] |> rename("TopLevel.Renamed.Bar") + + assert result =~ ~S[defmodule TopLevel.Renamed.Bar do] + end + + test "succeeds when simplifing the module name" do + {:ok, result} = + ~q[ + defmodule TopLevel.Foo.|Bar do + end + ] |> rename("TopLevel.Renamed") + + assert result =~ ~S[defmodule TopLevel.Renamed do] + end + + test "succeeds when the definition is in a nested module" do + {:ok, result} = + ~q[ + defmodule TopLevel do + defmodule |Foo do + end + end + + defmodule TopLevelTest do + alias TopLevel.Foo + end + ] |> rename("Renamed") + + assert result == ~q[ + defmodule TopLevel do + defmodule Renamed do + end + end + + defmodule TopLevelTest do + alias TopLevel.Renamed + end + ] + end + + test "succeeds when the cursor is in the multiple aliases off of single alias" do + {:ok, result} = + ~q[ + defmodule Foo.|Second do + end + + defmodule TopLevel do + alias Foo.{ + First, Second, + Third.Fourth + } + end + ] |> rename("Foo.Renamed") + + assert result =~ ~S[ First, Renamed,] + end + + test "shouldn't rename the relative module" do + {:ok, result} = + ~q[ + defmodule |Foo do + end + + defmodule FooTest do + end + ] |> rename("Renamed") + + assert result =~ ~S[defmodule FooTest do] + end + end + + describe "rename descendants" do + test "rename the descendants" do + {:ok, result} = ~q[ + defmodule TopLevel.|Module do + end + + defmodule TopLevel.Module.Another do + end + ] |> rename("TopLevel.Renamed") + + assert result =~ ~S[defmodule TopLevel.Renamed.Another] + assert result =~ ~S[defmodule TopLevel.Renamed do] + end + + test "succeeds rename the descendants when expanding the module name" do + {:ok, result} = ~q[ + defmodule TopLevel.|Module do + alias TopLevel.Module.Another + end + + defmodule TopLevel.Module.Another do + end + ] |> rename("TopLevel.ModuleRenamed") + + assert result =~ ~S[defmodule TopLevel.ModuleRenamed] + assert result =~ ~S[alias TopLevel.ModuleRenamed.Another] + assert result =~ ~S[defmodule TopLevel.ModuleRenamed.Another do] + end + + test "succeeds when expanding the module name with multiple dots" do + {:ok, result} = + ~q[ + defmodule TopLevel.|Bar do + end + + defmodule TopLevel.Bar.Baz do + end + + defmodule TopLevel.BarTest do + alias TopLevel.Bar + alias TopLevel.Bar.Baz + end + ] |> rename("TopLevel.Bar.Renamed") + + assert result =~ ~S[defmodule TopLevel.Bar.Renamed do] + assert result =~ ~S[alias TopLevel.Bar.Renamed] + assert result =~ ~S[alias TopLevel.Bar.Renamed.Baz] + end + + test "succeeds when there are same module name is in the cursor neighborhood" do + {:ok, result} = + ~q[ + defmodule Foo.Bar.Foo.|Bar do + end + + defmodule Foo.Bar.Foo.Bar.Baz do + end + + defmodule TopLevel.Another do + alias Foo.Bar.Foo.Bar.Baz + end + ] |> rename("Foo.Bar.Foo.Renamed") + + assert result =~ ~S[defmodule Foo.Bar.Foo.Renamed do] + assert result =~ ~S[defmodule Foo.Bar.Foo.Renamed.Baz do] + assert result =~ ~S[alias Foo.Bar.Foo.Renamed.Baz] + end + + test "succeeds even if there are descendants with the same name" do + {:ok, result} = + ~q[ + defmodule TopLevel.|Foo do + defmodule Foo do # skip this + end + end + + defmodule TopLevel.Bar do + alias TopLevel.Foo.Foo + end + ] + |> rename("TopLevel.Renamed") + + assert result =~ ~S[defmodule TopLevel.Renamed do] + assert result =~ ~S[defmodule Foo do # skip this] + assert result =~ ~S[alias TopLevel.Renamed.Foo] + end + + test "it shouldn't rename the descendant module if the module only contains old suffix" do + {:ok, result} = + ~q[ + defmodule |TopLevel.Ast do + end + + defmodule TopLevel.AnotherModule do + alias TopLevel.Ast.Detection + + Detection.Bitstring.detected?() # Bitstring contains the old suffix: `st` + end + ] |> rename("TopLevel.AST") + + refute result =~ ~S[Detection.BitSTring.detected?()] + end + end + + describe "rename struct" do + test "succeeds when the cursor is at the definition" do + {:ok, result} = + ~q[ + defmodule |Foo do + defstruct bar: 1 + end + + defmodule Bar do + def foo do + %Foo{} + end + end + ] |> rename("Renamed") + + assert result =~ ~S[defmodule Renamed do] + assert result =~ ~S[%Renamed{}] + end + end + + defp rename(%Project{} = project \\ project(), source, new_name) do + uri = subject_uri(project) + + with {position, text} <- pop_cursor(source), + {:ok, document} <- open_document(uri, text), + {:ok, entries} <- Search.Indexer.Source.index(document.path, text), + :ok <- Search.Store.replace(entries), + analysis = Lexical.Ast.analyze(document), + {:ok, uri_with_changes} <- Rename.rename(analysis, position, new_name) do + changes = uri_with_changes |> Map.values() |> List.flatten() + {:ok, apply_edits(document, changes)} + end + end + + defp prepare(project \\ project(), code) do + uri = subject_uri(project) + + with {position, text} <- pop_cursor(code), + {:ok, document} <- open_document(uri, text), + {:ok, entries} <- Search.Indexer.Source.index(document.path, text), + :ok <- Search.Store.replace(entries), + analysis = Lexical.Ast.analyze(document), + {:ok, result} <- Rename.prepare(analysis, position) do + result + end + end + + defp subject_uri(project) do + project + |> file_path(Path.join("lib", "project.ex")) + |> Document.Path.ensure_uri() + end + + defp open_document(uri, content) do + with :ok <- Document.Store.open(uri, content, 0) do + Document.Store.fetch(uri) + end + end + + def apply_edits(document, text_edits) do + {:ok, edited_document} = Document.apply_content_changes(document, 1, text_edits) + edited_document = Document.to_string(edited_document) + edited_document + end +end diff --git a/apps/server/lib/lexical/server.ex b/apps/server/lib/lexical/server.ex index 1d36d60e4..2ceeb362d 100644 --- a/apps/server/lib/lexical/server.ex +++ b/apps/server/lib/lexical/server.ex @@ -191,6 +191,12 @@ defmodule Lexical.Server do %Requests.WorkspaceSymbol{} -> {:ok, Handlers.WorkspaceSymbol} + %Requests.PrepareRename{} -> + {:ok, Handlers.PrepareRename} + + %Requests.Rename{} -> + {:ok, Handlers.Rename} + %request_module{} -> {:error, {:unhandled, request_module}} end diff --git a/apps/server/lib/lexical/server/provider/handlers/prepare_rename.ex b/apps/server/lib/lexical/server/provider/handlers/prepare_rename.ex new file mode 100644 index 000000000..892065b4e --- /dev/null +++ b/apps/server/lib/lexical/server/provider/handlers/prepare_rename.ex @@ -0,0 +1,40 @@ +defmodule Lexical.Server.Provider.Handlers.PrepareRename do + alias Lexical.Ast + alias Lexical.Document + alias Lexical.Protocol.Requests.PrepareRename + alias Lexical.Protocol.Responses + alias Lexical.Protocol.Types + alias Lexical.RemoteControl.Api + alias Lexical.Server.Provider.Env + + def handle(%PrepareRename{} = request, %Env{} = env) do + case Document.Store.fetch(request.document.uri, :analysis) do + {:ok, _document, %Ast.Analysis{valid?: true} = analysis} -> + prepare_rename(env.project, analysis, request.position, request.id) + + _ -> + {:reply, + Responses.PrepareRename.error( + request.id, + :request_failed, + "document can not be analyzed" + )} + end + end + + defp prepare_rename(project, analysis, position, id) do + case Api.prepare_rename(project, analysis, position) do + {:ok, cursor_entity, range} -> + result = + Types.PrepareRenameResult.PrepareRenameResult.new( + placeholder: cursor_entity, + range: range + ) + + {:reply, Responses.PrepareRename.new(id, result)} + + _ -> + {:reply, Responses.PrepareRename.new(id, nil)} + end + end +end diff --git a/apps/server/lib/lexical/server/provider/handlers/rename.ex b/apps/server/lib/lexical/server/provider/handlers/rename.ex new file mode 100644 index 000000000..31abbaced --- /dev/null +++ b/apps/server/lib/lexical/server/provider/handlers/rename.ex @@ -0,0 +1,39 @@ +defmodule Lexical.Server.Provider.Handlers.Rename do + alias Lexical.Ast + alias Lexical.Document + alias Lexical.Protocol.Requests.Rename + alias Lexical.Protocol.Responses + alias Lexical.Protocol.Types.Workspace.Edit + alias Lexical.RemoteControl.Api + alias Lexical.Server.Provider.Env + require Logger + + def handle(%Rename{} = request, %Env{} = env) do + case Document.Store.fetch(request.document.uri, :analysis) do + {:ok, _document, %Ast.Analysis{valid?: true} = analysis} -> + rename(env.project, analysis, request.position, request.new_name, request.id) + + _ -> + {:reply, + Responses.Rename.error(request.id, :request_failed, "document can not be analyzed")} + end + end + + defp rename(project, analysis, position, new_name, id) do + case Api.rename(project, analysis, position, new_name) do + {:ok, results} when results == %{} -> + {:reply, nil} + + {:ok, results} -> + edit = Edit.new(changes: results) + {:reply, Responses.Rename.new(id, edit)} + + {:error, {:unsupported_entity, entity}} -> + Logger.info("Unrenameable entity: #{inspect(entity)}") + {:reply, nil} + + {:error, reason} -> + {:reply, Responses.Rename.error(id, :request_failed, inspect(reason))} + end + end +end diff --git a/apps/server/lib/lexical/server/state.ex b/apps/server/lib/lexical/server/state.ex index a9b8a0d97..a023643e0 100644 --- a/apps/server/lib/lexical/server/state.ex +++ b/apps/server/lib/lexical/server/state.ex @@ -293,8 +293,10 @@ defmodule Lexical.Server.State do execute_command_provider: command_options, hover_provider: true, references_provider: true, + rename_provider: Types.Rename.Options.new(prepare_provider: true), text_document_sync: sync_options, - workspace_symbol_provider: true + workspace_symbol_provider: true, + text_document_sync: sync_options ) result = diff --git a/apps/server/test/lexical/server/provider/handlers/rename_test.exs b/apps/server/test/lexical/server/provider/handlers/rename_test.exs new file mode 100644 index 000000000..f4cd62703 --- /dev/null +++ b/apps/server/test/lexical/server/provider/handlers/rename_test.exs @@ -0,0 +1,106 @@ +defmodule Lexical.Server.Provider.Handlers.RenameTest do + alias Lexical.Ast + alias Lexical.Document + alias Lexical.Proto.Convert + alias Lexical.Protocol.Requests.Rename + alias Lexical.RemoteControl + + alias Lexical.Server + alias Lexical.Server.Provider.Env + alias Lexical.Server.Provider.Handlers + + import Lexical.Test.Protocol.Fixtures.LspProtocol + import Lexical.Test.Fixtures + + use ExUnit.Case, async: false + use Patch + + setup_all do + start_supervised(Server.Application.document_store_child_spec()) + :ok + end + + setup do + project = project(:navigations) + path = file_path(project, Path.join("lib", "my_definition.ex")) + uri = Document.Path.ensure_uri(path) + {:ok, project: project, uri: uri} + end + + def build_request(path, line, char) do + uri = Document.Path.ensure_uri(path) + + params = [ + text_document: [uri: uri], + position: [line: line, character: char] + ] + + with {:ok, _} <- Document.Store.open_temporary(uri), + {:ok, req} <- build(Rename, params) do + Convert.to_native(req) + end + end + + def handle(request, project) do + Handlers.Rename.handle(request, %Env{project: project}) + end + + describe "rename" do + test "returns nil when document can not be analyzed", %{project: project, uri: uri} do + patch(Document.Store, :fetch, fn ^uri, :analysis -> + {:ok, nil, %Ast.Analysis{valid?: false}} + end) + + {:ok, request} = build_request(uri, 1, 5) + assert {:reply, response} = handle(request, project) + + assert response.error.message == "document can not be analyzed" + end + + test "returns nil when there are no changes", %{project: project, uri: uri} do + patch(Document.Store, :fetch, fn ^uri, :analysis -> + {:ok, nil, %Ast.Analysis{valid?: true}} + end) + + patch(RemoteControl.Api, :rename, fn ^project, _analysis, _position, _new_name -> + {:ok, %{}} + end) + + {:ok, request} = build_request(uri, 1, 5) + assert {:reply, response} = handle(request, project) + + assert response == nil + end + + test "returns edit when there are changes", %{project: project, uri: uri} do + patch(Document.Store, :fetch, fn ^uri, :analysis -> + {:ok, nil, %Ast.Analysis{valid?: true}} + end) + + patch(RemoteControl.Api, :rename, fn ^project, _analysis, _position, _new_name -> + {:ok, + %{ + "file:///path/to/file.ex" => [ + %{ + new_text: "new_text", + range: %{start: %{line: 1, character: 5}, end: %{line: 1, character: 10}} + } + ] + }} + end) + + {:ok, request} = build_request(uri, 1, 5) + + assert {:reply, response} = handle(request, project) + + assert response.result.changes == %{ + "file:///path/to/file.ex" => [ + %{ + new_text: "new_text", + range: %{end: %{character: 10, line: 1}, start: %{character: 5, line: 1}} + } + ] + } + end + end +end