Skip to content

Commit

Permalink
This PR is primarily copied from #374, but compared to the previous i…
Browse files Browse the repository at this point in the history
…mplementation, 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.
  • Loading branch information
scottming committed Apr 2, 2024
1 parent bf2bab3 commit 3da3447
Show file tree
Hide file tree
Showing 7 changed files with 331 additions and 259 deletions.
20 changes: 0 additions & 20 deletions apps/common/lib/lexical/ast/module.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,4 @@ defmodule Lexical.Ast.Module do
|> inspect()
|> name()
end

@doc """
local module name is the last part of a module name
## Examples:
iex> local_name("Lexical.Ast.Module")
"Module"
"""
def local_name(entity) when is_atom(entity) do
entity
|> inspect()
|> local_name()
end

def local_name(entity) when is_binary(entity) do
entity
|> String.split(".")
|> List.last()
end
end
4 changes: 2 additions & 2 deletions apps/remote_control/lib/lexical/remote_control/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ defmodule Lexical.RemoteControl.Api do
%Analysis{} = analysis,
%Position{} = position
) do
RemoteControl.call(project, CodeIntelligence.Rename.Module, :prepare, [analysis, position])
RemoteControl.call(project, CodeMod.Rename, :prepare, [analysis, position])
end

def rename(%Project{} = project, %Analysis{} = analysis, %Position{} = position, new_name) do
RemoteControl.call(project, CodeIntelligence.Rename.Module, :rename, [
RemoteControl.call(project, CodeMod.Rename, :rename, [
analysis,
position,
new_name
Expand Down
26 changes: 26 additions & 0 deletions apps/remote_control/lib/lexical/remote_control/code_mod/rename.ex
Original file line number Diff line number Diff line change
@@ -1,2 +1,28 @@
defmodule Lexical.RemoteControl.CodeMod.Rename do
alias Lexical.Ast.Analysis
alias Lexical.Document.Edit
alias Lexical.Document.Position
alias Lexical.Document.Range
alias Lexical.RemoteControl.CodeMod.Rename.Prepare

@spec prepare(Analysis.t(), Position.t()) ::
{:ok, {atom(), String.t()}, Range.t()} | {:error, term()}
def prepare(%Analysis{} = analysis, %Position{} = position) do
Prepare.prepare(analysis, position)
end

@renamable_mapping %{call: __MODULE__.Callable, module: __MODULE__.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
case Prepare.resolve(analysis, position) do
{:ok, {renamable, entity}, range} ->
rename_module = @renamable_mapping[renamable]
{:ok, rename_module.rename(range, new_name, entity)}

{:error, error} ->
{:error, error}
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule Lexical.RemoteControl.CodeMod.Rename.Callable do
alias Lexical.Ast.Analysis
alias Lexical.Document.Position

alias Lexical.RemoteControl.CodeIntelligence.Entity

def resolve(%Analysis{} = analysis, %Position{} = position) do
case Entity.resolve(analysis, position) do
{:ok, {callable, module, local_name, _}, range} when callable in [:call] ->
{:ok, {:call, {module, local_name}}, range}

_ ->
{:error, :not_a_callable}
end
end
end
Original file line number Diff line number Diff line change
@@ -1,127 +1,144 @@
defmodule Lexical.RemoteControl.CodeMod.Rename.Module do
alias Lexical.Ast
alias Lexical.Ast.Analysis
alias Lexical.Document
alias Lexical.Document.Edit
alias Lexical.Document.Line
alias Lexical.Document.Position
alias Lexical.RemoteControl.CodeMod.Rename.Prepare
alias Lexical.Document.Range
alias Lexical.RemoteControl.CodeIntelligence.Entity
alias Lexical.RemoteControl.Search.Store
require Logger

@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, entity, range} <- Prepare.resolve_module(analysis, position) do
edits =
analysis.document
|> search_related_candidates(position, entity, range)
|> to_edits_by_uri(new_name)
import Line

{:ok, edits}
end
end

defp search_related_candidates(document, position, entity, range) do
local_module_name = Prepare.local_module_name(range)
entities = exacts(entity, local_module_name)

# Users won't always want to rename descendants of a module.
# For instance, when there are no more submodules after the cursor.
# like: `defmodule TopLevel.Mo|dule do`
# in most cases, users only want to rename the module itself.
#
# However, there's an exception when the cursor is in the middle,
# such as `Top.Mo|dule.ChildModule`. If we rename it to `Top.Renamed.Child`,
# it would be natural to also rename `Module.ChildModule` to `Renamed.Child`.
if at_the_middle_of_module?(document, position, range) do
entities ++ descendants(entity, local_module_name)
else
entities
end
@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)
to_edits_by_uri(results, new_suffix)
end

defp at_the_middle_of_module?(document, position, range) do
range_text = Prepare.range_text(range)

case Ast.surround_context(document, position) do
{:ok, %{context: {:alias, alias}}} ->
String.length(range_text) < length(alias)
@spec resolve(Analysis.t(), 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}

_ ->
false
{:error, :not_a_module}
end
end

defp descendants(entity, local_module_name) do
entity_string = inspect(entity)
prefix = "#{entity_string}."
defp diff(old_range_text, new_name) do
diff = String.myers_difference(old_range_text, new_name)

prefix
|> Store.prefix(type: :module)
|> Enum.filter(&(entry_matching?(&1, local_module_name) and has_dots_in_range?(&1)))
|> adjust_range(entity)
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, local_module_name) do
defp exacts(entity, old_suffix) do
entity_string = inspect(entity)

entity_string
|> Store.exact(type: :module)
|> Enum.filter(&entry_matching?(&1, local_module_name))
|> 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, local_module_name) do
entry.range |> Prepare.range_text() |> String.contains?(local_module_name)
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 |> Prepare.range_text() |> String.contains?(".")
entry.range |> range_text() |> String.contains?(".")
end

defp adjust_range(entries, entity) do
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,
uri = Document.Path.ensure_uri(entry.path),
range_result = resolve_local_module_range(uri, entry.range.start, entity),
match?({:ok, _}, range_result) do
{_, range} = range_result
range_text = range_text(entry.range),
matches = Regex.scan(~r"#{old_suffix}", range_text, return: :index),
result = resolve_module_range(entry, entity, matches),
match?({:ok, _}, result) do
{_, range} = result
%{entry | range: range}
end
end

defp resolve_local_module_range(uri, position, entity) do
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})
{:ok, range}
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`
uri = Document.Path.ensure_uri(entry.path)

with {:ok, _} <- Document.Store.open_temporary(uri),
{:ok, document, analysis} <- Document.Store.fetch(uri, :analysis),
{:ok, result, range} <- Prepare.resolve_module(analysis, position) do
{:ok, _document, analysis} <- Document.Store.fetch(uri, :analysis),
start_character = entry.range.start.character + start,
position = %{entry.range.start | character: start_character},
{:ok, {:module, result}, range} <- resolve(analysis, position) do
if result == entity do
range = adjust_range_characters(range, {start, length})
{:ok, range}
else
last_part_length = result |> Ast.Module.local_name() |> String.length()
# Move the cursor to the next part:
# `|Parent.Next.Target.Child` -> 'Parent.|Next.Target.Child' -> 'Parent.Next.|Target.Child'
character = position.character + last_part_length + 1
position = Position.new(document, position.line, character)
resolve_local_module_range(uri, position, entity)
resolve_module_range(entry, entity, tail)
end
else
_ ->
Logger.error("Failed to find entity range for #{inspect(uri)} at #{inspect(position)}")
:error
end
end

defp to_edits_by_uri(results, new_name) do
defp adjust_range_characters(range, {start, length} = _matched_old_suffix) do
start_character = range.start.character + start
end_character = start_character + length
start_position = %{range.start | character: start_character}
end_end_position = %{range.start | character: end_character}
%{range | start: start_position, end: end_end_position}
end

defp to_edits_by_uri(results, new_suffix) do
Enum.group_by(
results,
&Document.Path.ensure_uri(&1.path),
fn result ->
local_module_name_length = result.range |> Prepare.local_module_name() |> String.length()
# e.g: `Parent.|ToBeRenameModule`, we need the start position of `ToBeRenameModule`
start_character = result.range.end.character - local_module_name_length
start_position = %{result.range.start | character: start_character}

new_range = %{result.range | start: start_position}
Edit.new(new_name, new_range)
end
&Edit.new(new_suffix, &1.range)
)
end
end
Loading

0 comments on commit 3da3447

Please sign in to comment.