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..616e7d8a9 --- /dev/null +++ b/apps/remote_control/lib/lexical/remote_control/code_mod/rename/module.ex @@ -0,0 +1,164 @@ +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_string = inspect(entity) + + entity_string + |> Store.exact(type: :module) + |> Enum.filter(&entry_matching?(&1, old_suffix)) + |> adjust_range_for_exacts(old_suffix) + end + + defp descendants(entity, old_suffix) do + prefix = "#{inspect(entity)}." + + prefix + |> Store.prefix(type: :module) + |> Enum.filter(&(entry_matching?(&1, old_suffix) and has_dots_in_range?(&1))) + |> adjust_range_for_descendants(entity, old_suffix) + 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..dc4604366 --- /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 + [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/provider/handlers.ex b/apps/server/lib/lexical/server/provider/handlers.ex index 679b71a32..9545174b0 100644 --- a/apps/server/lib/lexical/server/provider/handlers.ex +++ b/apps/server/lib/lexical/server/provider/handlers.ex @@ -3,6 +3,7 @@ defmodule Lexical.Server.Provider.Handlers do alias Lexical.Protocol.Requests alias Lexical.Server.Provider.Handlers + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity def for_request(%_{} = request) do case request do %Requests.FindReferences{} -> @@ -35,6 +36,12 @@ defmodule Lexical.Server.Provider.Handlers 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 76683c19e..c018b7232 100644 --- a/apps/server/lib/lexical/server/state.ex +++ b/apps/server/lib/lexical/server/state.ex @@ -294,8 +294,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