Skip to content

Commit

Permalink
Rename file when renaming module (#651)
Browse files Browse the repository at this point in the history
* Rename the file while renaming the module.

It first determines whether the module meets certain hard constraints, such as having no parent, then performs some conventional checks. If both are satisfied, it proceeds to return the file renaming.

* Maybe insert special folder for PhoenixWeb's module

* Apply some code review changes for using `Document.Changes`

Use `Document.Changes` instead of `CodeMod.Rename.DocumentChanges`

Make `Document.Changes.RenameFile` a struct and use `root_module?`
instead of `has_silibings?` and `has_parent?`

* Use `rename progress marker` instead of suspending build

* Use `file_changed` instead of `file_compile_requested` for reindexing

* `uri_with_operations_counts` by client name

* Apply some code review suggestions for `rename/file`

* Apply suggestions from code review

Co-authored-by: Steve Cohen <[email protected]>

* Do not need to care about the `DidClose` message

* Apply some code review suggestions

1. Move the `if-else` logic to the `Commands.Rename` module
2. Add `file_opened` message type
3. modify the format error message

* Shouldn't returns `RenameFile` if the file name not changed

* Change `uri_with_operation_counts` to `uri_with_expected_operation`

and special some message type for emacs

---------

Co-authored-by: Steve Cohen <[email protected]>
  • Loading branch information
scottming and scohen authored Apr 26, 2024
1 parent 3729362 commit a971f2a
Show file tree
Hide file tree
Showing 20 changed files with 768 additions and 85 deletions.
26 changes: 24 additions & 2 deletions apps/remote_control/lib/lexical/remote_control/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ defmodule Lexical.RemoteControl.Api do
defdelegate schedule_compile(project, force?), to: Build
defdelegate compile_document(project, document), to: Build

def maybe_schedule_compile(project, triggered_file_uri, triggered_message) do
RemoteControl.call(project, Build, :maybe_schedule_compile, [
triggered_file_uri,
triggered_message
])
end

def maybe_compile_document(project, document, triggered_message) do
RemoteControl.call(project, Build, :maybe_compile_document, [
project,
document,
triggered_message
])
end

def expand_alias(
%Project{} = project,
segments_or_module,
Expand Down Expand Up @@ -55,11 +70,18 @@ defmodule Lexical.RemoteControl.Api do
RemoteControl.call(project, CodeMod.Rename, :prepare, [analysis, position])
end

def rename(%Project{} = project, %Analysis{} = analysis, %Position{} = position, new_name) do
def rename(
%Project{} = project,
%Analysis{} = analysis,
%Position{} = position,
new_name,
client_name
) do
RemoteControl.call(project, CodeMod.Rename, :rename, [
analysis,
position,
new_name
new_name,
client_name
])
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ defmodule Lexical.RemoteControl.Api.Messages do

defrecord :file_changed, uri: nil, from_version: nil, to_version: nil, open?: false

defrecord :file_opened, uri: nil, version: nil

defrecord :file_compile_requested, project: nil, build_number: 0, uri: nil

defrecord :file_compiled,
Expand Down Expand Up @@ -79,6 +81,12 @@ defmodule Lexical.RemoteControl.Api.Messages do
open?: boolean()
)

@type file_opened ::
record(:file_opened,
uri: Lexical.uri(),
version: non_neg_integer()
)

@type file_compile_requested ::
record(:file_compile_requested,
project: Lexical.Project.t(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defmodule Lexical.RemoteControl.Application do
if RemoteControl.project_node?() do
[
{RemoteControl.Commands.Reindex, nil},
RemoteControl.Commands.Rename,
RemoteControl.Module.Loader,
{RemoteControl.Dispatch, progress: true},
RemoteControl.ModuleMappings,
Expand Down
17 changes: 17 additions & 0 deletions apps/remote_control/lib/lexical/remote_control/build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule Lexical.RemoteControl.Build do
alias Lexical.RemoteControl
alias Lexical.RemoteControl.Build.Document.Compilers.HEEx
alias Lexical.RemoteControl.Build.State
alias Lexical.RemoteControl.Commands.Rename
alias Lexical.VM.Versions

require Logger
Expand Down Expand Up @@ -35,6 +36,22 @@ defmodule Lexical.RemoteControl.Build do
:ok
end

def maybe_schedule_compile(triggered_file_uri, message) do
if Rename.in_progress?() do
Rename.update_progress(triggered_file_uri, message)
else
GenServer.cast(__MODULE__, {:compile, false})
end
end

def maybe_compile_document(%Project{} = project, %Document{} = document, message) do
if Rename.in_progress?() do
Rename.update_progress(document.uri, message)
else
compile_document(project, document)
end
end

# this is for testing
def force_compile_document(%Project{} = project, %Document{} = document) do
with false <- Path.absname(document.path) == "mix.exs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ defmodule Lexical.RemoteControl.CodeIntelligence.Entity do
)
end

@spec phoenix_controller_module?(module()) :: boolean()
def phoenix_controller_module?(module) do
function_exists?(module, :call, 2) and function_exists?(module, :action, 2)
end

@spec phoenix_liveview_module?(module()) :: boolean()
def phoenix_liveview_module?(module) do
function_exists?(module, :mount, 3) and function_exists?(module, :render, 1)
end

@spec phoenix_component_module?(module()) :: boolean()
def phoenix_component_module?(module) do
function_exists?(module, :__components__, 0) and
function_exists?(module, :__phoenix_component_verify__, 1)
end

defp check_commented(%Analysis{} = analysis, %Position{} = position) do
if Analysis.commented?(analysis, position) do
:error
Expand Down Expand Up @@ -247,14 +263,6 @@ defmodule Lexical.RemoteControl.CodeIntelligence.Entity do
end
end

defp phoenix_controller_module?(module) do
function_exists?(module, :call, 2) and function_exists?(module, :action, 2)
end

defp phoenix_liveview_module?(module) do
function_exists?(module, :mount, 3) and function_exists?(module, :render, 1)
end

# Take only the segments at and before the cursor, e.g.
# Foo|.Bar.Baz -> Foo
# Foo.|Bar.Baz -> Foo.Bar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,30 @@ defmodule Lexical.RemoteControl.CodeMod.Format do
alias Lexical.RemoteControl
alias Lexical.RemoteControl.Build
alias Lexical.RemoteControl.CodeMod.Diff
alias Lexical.RemoteControl.Commands.Rename

require Logger

@type formatter_function :: (String.t() -> any) | nil

@spec edits(Project.t(), Document.t()) :: {:ok, Changes.t()} | {:error, any}
def edits(%Project{} = project, %Document{} = document) do
with :ok <- Build.compile_document(project, document),
with :ok <- ensure_not_renaming(),
:ok <- Build.compile_document(project, document),
{:ok, formatted} <- do_format(project, document) do
edits = Diff.diff(document, formatted)
{:ok, Changes.new(document, edits)}
end
end

defp ensure_not_renaming do
if Rename.in_progress?() do
{:error, :rename_in_progress}
else
:ok
end
end

defp do_format(%Project{} = project, %Document{} = document) do
project_path = Project.project_path(project)

Expand Down
50 changes: 45 additions & 5 deletions apps/remote_control/lib/lexical/remote_control/code_mod/rename.ex
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
defmodule Lexical.RemoteControl.CodeMod.Rename do
alias Lexical.Ast.Analysis
alias Lexical.Document.Edit
alias Lexical.Document
alias Lexical.Document.Position
alias Lexical.Document.Range
alias Lexical.Protocol.Notifications.DidChange
alias Lexical.Protocol.Notifications.DidSave
alias Lexical.RemoteControl.Commands

alias __MODULE__

@spec prepare(Analysis.t(), Position.t()) ::
Expand All @@ -11,12 +15,48 @@ defmodule Lexical.RemoteControl.CodeMod.Rename do

@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
@spec rename(Analysis.t(), Position.t(), String.t(), String.t() | nil) ::
{:ok, [Document.Changes.t()]} | {:error, term()}
def rename(%Analysis{} = analysis, %Position{} = position, new_name, client_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)}
results = rename_module.rename(range, new_name, entity)
set_rename_progress(results, client_name)
{:ok, results}
end
end

defp set_rename_progress(document_changes_list, client_name) do
client_name
|> uri_with_expected_operation(document_changes_list)
|> Commands.Rename.set_rename_progress()
end

defp uri_with_expected_operation(client_name, document_changes_list)
when client_name in ["Visual Studio Code", "emacs"] do
document_changes_list
|> Enum.flat_map(fn %Document.Changes{document: document, rename_file: rename_file} ->
if rename_file do
# when the file is renamed, we won't receive `DidSave` for the old file
[{rename_file.old_uri, DidChange}, {rename_file.new_uri, DidSave}]
else
[{document.uri, DidSave}]
end
end)
|> Map.new()
end

defp uri_with_expected_operation(_, document_changes_list) do
document_changes_list
|> Enum.flat_map(fn %Document.Changes{document: document, rename_file: rename_file} ->
if rename_file do
[{document.uri, DidSave}]
else
# Some editors do not directly save the file after renaming, such as *neovim*.
# when the file is not renamed, we'll only received `DidChange` for the old file
[{document.uri, DidChange}]
end
end)
|> Map.new()
end
end
154 changes: 154 additions & 0 deletions apps/remote_control/lib/lexical/remote_control/code_mod/rename/file.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
defmodule Lexical.RemoteControl.CodeMod.Rename.File do
alias Lexical.Ast
alias Lexical.Document
alias Lexical.ProcessCache
alias Lexical.Project
alias Lexical.RemoteControl
alias Lexical.RemoteControl.CodeIntelligence.Entity
alias Lexical.RemoteControl.Search.Indexer
alias Lexical.RemoteControl.Search.Indexer.Entry

@spec maybe_rename(Document.t(), Entry.t(), String.t()) :: Document.Changes.rename_file()
def maybe_rename(%Document{} = document, %Entry{} = entry, new_suffix) do
if root_module?(entry, document) do
rename_file(document, entry, new_suffix)
end
end

defp root_module?(entry, document) do
entries =
ProcessCache.trans("#{document.uri}-entries", 50, fn ->
with {:ok, entries} <-
Indexer.Source.index_document(document, [Indexer.Extractors.Module]) do
entries
end
end)

case Enum.filter(entries, &(&1.block_id == :root)) do
[root_module] ->
root_module.subject == entry.subject and root_module.block_range == entry.block_range

_ ->
false
end
end

defp rename_file(document, entry, new_suffix) do
root_path = root_path()
relative_path = relative_path(entry.path, root_path)

with {:ok, prefix} <- fetch_conventional_prefix(relative_path),
{:ok, new_name} <- fetch_new_name(document, entry, new_suffix) do
extname = Path.extname(entry.path)

suffix =
new_name
|> Macro.underscore()
|> maybe_insert_special_phoenix_folder(entry.subject, relative_path)

new_path = Path.join([root_path, prefix, "#{suffix}#{extname}"])
new_uri = Document.Path.ensure_uri(new_path)

if document.uri != new_uri do
Document.Changes.RenameFile.new(document.uri, new_uri)
end
else
_ -> nil
end
end

defp relative_path(path, root_path) do
Path.relative_to(path, root_path)
end

defp root_path do
Project.root_path(RemoteControl.get_project())
end

defp fetch_new_name(document, entry, new_suffix) do
text_edits = [Document.Edit.new(new_suffix, entry.range)]

with {:ok, edited_document} <-
Document.apply_content_changes(document, document.version + 1, text_edits),
{:ok, %{context: {:alias, alias}}} <-
Ast.surround_context(edited_document, entry.range.start) do
{:ok, to_string(alias)}
else
_ -> :error
end
end

defp fetch_conventional_prefix(path) do
# To obtain the new relative path, we can't directly convert from the *new module* name.
# We also need a part of the prefix, and Elixir has some conventions in this regard,
# For example:
#
# in umbrella projects, the prefix is `Path.join(["apps", app_name, "lib"])`
# in non-umbrella projects, most file's prefix is `"lib"`
#
# ## Examples
#
# iex> fetch_conventional_prefix("apps/remote_control/lib/lexical/remote_control/code_mod/rename/file.ex")
# {:ok, "apps/remote_control/lib"}
result =
path
|> Path.split()
|> Enum.chunk_every(2, 2)
|> Enum.reduce([], fn
["apps", app_name], _ ->
[app_name, "apps"]

["lib", _follow_element], prefix ->
["lib" | prefix]

["test", _follow_element], prefix ->
["test" | prefix]

_remain, prefix ->
prefix
end)

case result do
[] ->
:error

prefix ->
prefix = prefix |> Enum.reverse() |> Path.join()
{:ok, prefix}
end
end

defp maybe_insert_special_phoenix_folder(suffix, subject, relative_path) do
insertions =
cond do
Entity.phoenix_controller_module?(subject) ->
"controllers"

Entity.phoenix_liveview_module?(subject) ->
"live"

Entity.phoenix_component_module?(subject) ->
"components"

true ->
nil
end

# In some cases, users prefer to include the `insertions` in the module name,
# such as `DemoWeb.Components.Icons`.
# In this case, we should not insert the prefix in a nested manner.
prefer_to_include_insertions? = insertions && insertions in Path.split(suffix)
old_path_contains_insertions? = insertions in Path.split(relative_path)

if not is_nil(insertions) and old_path_contains_insertions? and
not prefer_to_include_insertions? do
suffix
|> Path.split()
|> List.insert_at(1, insertions)
|> Enum.reject(&(&1 == ""))
|> Path.join()
else
suffix
end
end
end
Loading

0 comments on commit a971f2a

Please sign in to comment.