Skip to content

Commit

Permalink
Introduced rename functionality across modules
Browse files Browse the repository at this point in the history
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 lexical-lsp#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 <[email protected]>

Apply the `defdelegate` suggestion

Fix a bug when the descendant containing the old suffix
  • Loading branch information
scottming committed Jul 12, 2024
1 parent 268c8ff commit 5bc85ac
Show file tree
Hide file tree
Showing 14 changed files with 944 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions apps/protocol/lib/lexical/protocol/requests.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions apps/protocol/lib/lexical/protocol/responses.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions apps/remote_control/lib/lexical/remote_control/code_mod/rename.ex
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 5bc85ac

Please sign in to comment.