Skip to content
This repository was archived by the owner on Aug 30, 2025. It is now read-only.

Commit c5d4b46

Browse files
committed
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.
1 parent 3bb8763 commit c5d4b46

7 files changed

Lines changed: 331 additions & 259 deletions

File tree

apps/common/lib/lexical/ast/module.ex

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,4 @@ defmodule Lexical.Ast.Module do
2828
|> inspect()
2929
|> name()
3030
end
31-
32-
@doc """
33-
local module name is the last part of a module name
34-
35-
## Examples:
36-
37-
iex> local_name("Lexical.Ast.Module")
38-
"Module"
39-
"""
40-
def local_name(entity) when is_atom(entity) do
41-
entity
42-
|> inspect()
43-
|> local_name()
44-
end
45-
46-
def local_name(entity) when is_binary(entity) do
47-
entity
48-
|> String.split(".")
49-
|> List.last()
50-
end
5131
end

apps/remote_control/lib/lexical/remote_control/api.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ defmodule Lexical.RemoteControl.Api do
5252
%Analysis{} = analysis,
5353
%Position{} = position
5454
) do
55-
RemoteControl.call(project, CodeIntelligence.Rename.Module, :prepare, [analysis, position])
55+
RemoteControl.call(project, CodeMod.Rename.Prepare, :prepare, [analysis, position])
5656
end
5757

5858
def rename(%Project{} = project, %Analysis{} = analysis, %Position{} = position, new_name) do
59-
RemoteControl.call(project, CodeIntelligence.Rename.Module, :rename, [
59+
RemoteControl.call(project, CodeMod.Rename, :rename, [
6060
analysis,
6161
position,
6262
new_name
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,28 @@
11
defmodule Lexical.RemoteControl.CodeMod.Rename do
2+
alias Lexical.Ast.Analysis
3+
alias Lexical.Document.Edit
4+
alias Lexical.Document.Position
5+
alias Lexical.Document.Range
6+
alias Lexical.RemoteControl.CodeMod.Rename.Prepare
7+
8+
@spec prepare(Analysis.t(), Position.t()) ::
9+
{:ok, {atom(), String.t()}, Range.t()} | {:error, term()}
10+
def prepare(%Analysis{} = analysis, %Position{} = position) do
11+
Prepare.prepare(analysis, position)
12+
end
13+
14+
@renamable_mapping %{call: __MODULE__.Callable, module: __MODULE__.Module}
15+
16+
@spec rename(Analysis.t(), Position.t(), String.t()) ::
17+
{:ok, %{Lexical.uri() => [Edit.t()]}} | {:error, term()}
18+
def rename(%Analysis{} = analysis, %Position{} = position, new_name) do
19+
case Prepare.resolve(analysis, position) do
20+
{:ok, {renamable, entity}, range} ->
21+
rename_module = @renamable_mapping[renamable]
22+
{:ok, rename_module.rename(range, new_name, entity)}
23+
24+
{:error, error} ->
25+
{:error, error}
26+
end
27+
end
228
end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
defmodule Lexical.RemoteControl.CodeMod.Rename.Callable do
2+
alias Lexical.Ast.Analysis
3+
alias Lexical.Document.Position
4+
5+
alias Lexical.RemoteControl.CodeIntelligence.Entity
6+
7+
def resolve(%Analysis{} = analysis, %Position{} = position) do
8+
case Entity.resolve(analysis, position) do
9+
{:ok, {callable, module, local_name, _}, range} when callable in [:call] ->
10+
{:ok, {:call, {module, local_name}}, range}
11+
12+
_ ->
13+
{:error, :not_a_callable}
14+
end
15+
end
16+
end
Lines changed: 95 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,144 @@
11
defmodule Lexical.RemoteControl.CodeMod.Rename.Module do
2-
alias Lexical.Ast
32
alias Lexical.Ast.Analysis
43
alias Lexical.Document
54
alias Lexical.Document.Edit
5+
alias Lexical.Document.Line
66
alias Lexical.Document.Position
7-
alias Lexical.RemoteControl.CodeMod.Rename.Prepare
7+
alias Lexical.Document.Range
8+
alias Lexical.RemoteControl.CodeIntelligence.Entity
89
alias Lexical.RemoteControl.Search.Store
910
require Logger
1011

11-
@spec rename(Analysis.t(), Position.t(), String.t()) ::
12-
{:ok, %{Lexical.uri() => [Edit.t()]}} | {:error, term()}
13-
def rename(%Analysis{} = analysis, %Position{} = position, new_name) do
14-
with {:ok, entity, range} <- Prepare.resolve_module(analysis, position) do
15-
edits =
16-
analysis.document
17-
|> search_related_candidates(position, entity, range)
18-
|> to_edits_by_uri(new_name)
12+
import Line
1913

20-
{:ok, edits}
21-
end
22-
end
23-
24-
defp search_related_candidates(document, position, entity, range) do
25-
local_module_name = Prepare.local_module_name(range)
26-
entities = exacts(entity, local_module_name)
27-
28-
# Users won't always want to rename descendants of a module.
29-
# For instance, when there are no more submodules after the cursor.
30-
# like: `defmodule TopLevel.Mo|dule do`
31-
# in most cases, users only want to rename the module itself.
32-
#
33-
# However, there's an exception when the cursor is in the middle,
34-
# such as `Top.Mo|dule.ChildModule`. If we rename it to `Top.Renamed.Child`,
35-
# it would be natural to also rename `Module.ChildModule` to `Renamed.Child`.
36-
if at_the_middle_of_module?(document, position, range) do
37-
entities ++ descendants(entity, local_module_name)
38-
else
39-
entities
40-
end
14+
@spec rename(Range.t(), String.t(), atom()) :: %{Lexical.uri() => [Edit.t()]}
15+
def rename(%Range{} = old_range, new_name, entity) do
16+
{old_suffix, new_suffix} = old_range |> range_text() |> diff(new_name)
17+
results = exacts(entity, old_suffix) ++ descendants(entity, old_suffix)
18+
to_edits_by_uri(results, new_suffix)
4119
end
4220

43-
defp at_the_middle_of_module?(document, position, range) do
44-
range_text = Prepare.range_text(range)
45-
46-
case Ast.surround_context(document, position) do
47-
{:ok, %{context: {:alias, alias}}} ->
48-
String.length(range_text) < length(alias)
21+
@spec resolve(Analysis.t(), Position.t()) ::
22+
{:ok, {atom(), atom()}, Range.t()} | {:error, term()}
23+
def resolve(%Analysis{} = analysis, %Position{} = position) do
24+
case Entity.resolve(analysis, position) do
25+
{:ok, {module_or_struct, module}, range} when module_or_struct in [:struct, :module] ->
26+
{:ok, {:module, module}, range}
4927

5028
_ ->
51-
false
29+
{:error, :not_a_module}
5230
end
5331
end
5432

55-
defp descendants(entity, local_module_name) do
56-
entity_string = inspect(entity)
57-
prefix = "#{entity_string}."
33+
defp diff(old_range_text, new_name) do
34+
diff = String.myers_difference(old_range_text, new_name)
5835

59-
prefix
60-
|> Store.prefix(type: :module)
61-
|> Enum.filter(&(entry_matching?(&1, local_module_name) and has_dots_in_range?(&1)))
62-
|> adjust_range(entity)
36+
eq =
37+
if match?([{:eq, _eq} | _], diff) do
38+
diff |> hd() |> elem(1)
39+
else
40+
""
41+
end
42+
43+
old_suffix = String.replace(old_range_text, ~r"^#{eq}", "")
44+
new_suffix = String.replace(new_name, ~r"^#{eq}", "")
45+
{old_suffix, new_suffix}
6346
end
6447

65-
defp exacts(entity, local_module_name) do
48+
defp exacts(entity, old_suffix) do
6649
entity_string = inspect(entity)
6750

6851
entity_string
6952
|> Store.exact(type: :module)
70-
|> Enum.filter(&entry_matching?(&1, local_module_name))
53+
|> Enum.filter(&entry_matching?(&1, old_suffix))
54+
|> adjust_range_for_exacts(old_suffix)
55+
end
56+
57+
defp descendants(entity, old_suffix) do
58+
prefix = "#{inspect(entity)}."
59+
60+
prefix
61+
|> Store.prefix(type: :module)
62+
|> Enum.filter(&(entry_matching?(&1, old_suffix) and has_dots_in_range?(&1)))
63+
|> adjust_range_for_descendants(entity, old_suffix)
7164
end
7265

73-
defp entry_matching?(entry, local_module_name) do
74-
entry.range |> Prepare.range_text() |> String.contains?(local_module_name)
66+
defp entry_matching?(entry, old_suffix) do
67+
entry.range |> range_text() |> String.contains?(old_suffix)
7568
end
7669

7770
defp has_dots_in_range?(entry) do
78-
entry.range |> Prepare.range_text() |> String.contains?(".")
71+
entry.range |> range_text() |> String.contains?(".")
7972
end
8073

81-
defp adjust_range(entries, entity) do
74+
defp adjust_range_for_exacts(entries, old_suffix) do
75+
for entry <- entries do
76+
start_character = entry.range.end.character - String.length(old_suffix)
77+
start_position = %{entry.range.start | character: start_character}
78+
range = %{entry.range | start: start_position}
79+
%{entry | range: range}
80+
end
81+
end
82+
83+
defp adjust_range_for_descendants(entries, entity, old_suffix) do
8284
for entry <- entries,
83-
uri = Document.Path.ensure_uri(entry.path),
84-
range_result = resolve_local_module_range(uri, entry.range.start, entity),
85-
match?({:ok, _}, range_result) do
86-
{_, range} = range_result
85+
range_text = range_text(entry.range),
86+
matches = Regex.scan(~r"#{old_suffix}", range_text, return: :index),
87+
result = resolve_module_range(entry, entity, matches),
88+
match?({:ok, _}, result) do
89+
{_, range} = result
8790
%{entry | range: range}
8891
end
8992
end
9093

91-
defp resolve_local_module_range(uri, position, entity) do
94+
defp range_text(range) do
95+
line(text: text) = range.end.context_line
96+
String.slice(text, range.start.character - 1, range.end.character - range.start.character)
97+
end
98+
99+
defp resolve_module_range(_entry, _entity, []) do
100+
{:error, :not_found}
101+
end
102+
103+
defp resolve_module_range(entry, _entity, [[{start, length}]]) do
104+
range = adjust_range_characters(entry.range, {start, length})
105+
{:ok, range}
106+
end
107+
108+
defp resolve_module_range(entry, entity, [[{start, length}] | tail] = _matches) do
109+
# This function is mainly for the duplicated suffixes
110+
# For example, if we have a module named `Foo.Bar.Foo.Bar` and we want to rename it to `Foo.Bar.Baz`
111+
# The `Foo.Bar` will be duplicated in the range text, so we need to resolve the correct range
112+
# and only rename the second occurrence of `Foo.Bar`
113+
uri = Document.Path.ensure_uri(entry.path)
114+
92115
with {:ok, _} <- Document.Store.open_temporary(uri),
93-
{:ok, document, analysis} <- Document.Store.fetch(uri, :analysis),
94-
{:ok, result, range} <- Prepare.resolve_module(analysis, position) do
116+
{:ok, _document, analysis} <- Document.Store.fetch(uri, :analysis),
117+
start_character = entry.range.start.character + start,
118+
position = %{entry.range.start | character: start_character},
119+
{:ok, {:module, result}, range} <- resolve(analysis, position) do
95120
if result == entity do
121+
range = adjust_range_characters(range, {start, length})
96122
{:ok, range}
97123
else
98-
last_part_length = result |> Ast.Module.local_name() |> String.length()
99-
# Move the cursor to the next part:
100-
# `|Parent.Next.Target.Child` -> 'Parent.|Next.Target.Child' -> 'Parent.Next.|Target.Child'
101-
character = position.character + last_part_length + 1
102-
position = Position.new(document, position.line, character)
103-
resolve_local_module_range(uri, position, entity)
124+
resolve_module_range(entry, entity, tail)
104125
end
105-
else
106-
_ ->
107-
Logger.error("Failed to find entity range for #{inspect(uri)} at #{inspect(position)}")
108-
:error
109126
end
110127
end
111128

112-
defp to_edits_by_uri(results, new_name) do
129+
defp adjust_range_characters(range, {start, length} = _matched_old_suffix) do
130+
start_character = range.start.character + start
131+
end_character = start_character + length
132+
start_position = %{range.start | character: start_character}
133+
end_end_position = %{range.start | character: end_character}
134+
%{range | start: start_position, end: end_end_position}
135+
end
136+
137+
defp to_edits_by_uri(results, new_suffix) do
113138
Enum.group_by(
114139
results,
115140
&Document.Path.ensure_uri(&1.path),
116-
fn result ->
117-
local_module_name_length = result.range |> Prepare.local_module_name() |> String.length()
118-
# e.g: `Parent.|ToBeRenameModule`, we need the start position of `ToBeRenameModule`
119-
start_character = result.range.end.character - local_module_name_length
120-
start_position = %{result.range.start | character: start_character}
121-
122-
new_range = %{result.range | start: start_position}
123-
Edit.new(new_name, new_range)
124-
end
141+
&Edit.new(new_suffix, &1.range)
125142
)
126143
end
127144
end

0 commit comments

Comments
 (0)