Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/engine/lib/engine.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ defmodule Engine do

defdelegate workspace_symbols(query), to: CodeIntelligence.Symbols, as: :for_workspace

defdelegate prepare_rename(analysis, position), to: Engine.CodeMod.Rename, as: :prepare

defdelegate rename(analysis, position, new_name, client_name), to: Engine.CodeMod.Rename

defdelegate maybe_update_rename_progress(triggered_message),
to: Engine.Commands.Rename,
as: :update_progress

def list_apps do
for {app, _, _} <- :application.loaded_applications(),
not Forge.Namespace.Module.prefixed?(app),
Expand Down
1 change: 1 addition & 0 deletions apps/engine/lib/engine/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule Engine.Application do
[
Engine.Api.Proxy,
Engine.Commands.Reindex,
Engine.Commands.RenameSupervisor,
Engine.Module.Loader,
Engine.Dispatch,
Engine.ModuleMappings,
Expand Down
128 changes: 128 additions & 0 deletions apps/engine/lib/engine/code_mod/rename.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
defmodule Engine.CodeMod.Rename do
@moduledoc """
Entry point for rename operations.

This module provides the main API for renaming entities (currently modules)
in Elixir code. It coordinates between the preparation phase and the actual
rename execution.
"""
alias Engine.CodeMod.Rename
alias Engine.Commands
alias Engine.Progress
alias Forge.Ast.Analysis
alias Forge.Document
alias Forge.Document.Position
alias Forge.Document.Range

import Forge.EngineApi.Messages

@doc """
Prepares a rename operation at the given position.

Returns `{:ok, entity_name, range}` if the entity can be renamed,
`{:ok, nil}` if at an unsupported location,
or `{:error, reason}` if renaming is not possible.
"""
@spec prepare(Analysis.t(), Position.t()) ::
{:ok, String.t(), Range.t()} | {:ok, nil} | {:error, term()}
defdelegate prepare(analysis, position), to: Rename.Prepare

@rename_mappings %{module: Rename.Module}

@doc """
Executes a rename operation.

Renames the entity at the given position to `new_name`, returning a list
of document changes that should be applied.

The `client_name` parameter is used to determine client-specific behavior
for progress tracking (e.g., VSCode sends different events than Neovim).
"""
@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 = Map.fetch!(@rename_mappings, renamable)
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
# Progress tracking is optional - if the infrastructure isn't running
# (e.g., in tests), we just skip it silently
try do
do_set_rename_progress(document_changes_list, client_name)
rescue
_ -> :ok
catch
:exit, _ -> :ok
end
end

defp do_set_rename_progress(document_changes_list, client_name) do
uri_to_expected_operation =
uri_to_expected_operation(client_name, document_changes_list)

{paths_to_delete, paths_to_reindex} =
for %Document.Changes{rename_file: rename_file, document: document} <- document_changes_list do
if rename_file do
{rename_file.old_uri, rename_file.new_uri}
else
{nil, document.uri}
end
end
|> Enum.unzip()

paths_to_delete = Enum.reject(paths_to_delete, &is_nil/1)
renaming_operation_count = Enum.count(uri_to_expected_operation)

total_operation_count =
renaming_operation_count + length(paths_to_delete) + length(paths_to_reindex)

{on_update_progress, on_complete} =
Progress.begin_percent("Renaming", total_operation_count)

Commands.RenameSupervisor.start_renaming(
uri_to_expected_operation,
paths_to_reindex,
paths_to_delete,
on_update_progress,
on_complete
)
end

# VSCode sends both file_changed and file_saved events
defp uri_to_expected_operation(client_name, document_changes_list)
when client_name in ["Visual Studio Code"] 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, file_changed(uri: rename_file.old_uri)},
{rename_file.new_uri, file_saved(uri: rename_file.new_uri)}
]
else
[{document.uri, file_saved(uri: document.uri)}]
end
end)
|> Map.new()
end

# Other editors (like Neovim) may only send file_changed events
defp uri_to_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
[{rename_file.new_uri, file_saved(uri: rename_file.new_uri)}]
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, file_changed(uri: document.uri)}]
end
end)
|> Map.new()
end
end
46 changes: 46 additions & 0 deletions apps/engine/lib/engine/code_mod/rename/entry.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
defmodule Engine.CodeMod.Rename.Entry do
@moduledoc """
An entry wrapper for search indexer entries used in rename operations.

When renaming, we rely on the `Forge.Search.Indexer.Entry`,
and we also need some other fields used exclusively for renaming, such as `edit_range`.
"""
alias Forge.Document.Range
alias Forge.Search.Indexer.Entry, as: IndexerEntry

@type t :: %__MODULE__{
id: IndexerEntry.entry_id(),
path: Forge.path(),
subject: IndexerEntry.subject(),
block_range: Range.t() | nil,
range: Range.t(),
edit_range: Range.t(),
subtype: IndexerEntry.entry_subtype()
}

defstruct [
:id,
:path,
:subject,
:block_range,
:range,
:edit_range,
:subtype
]

@doc """
Creates a new Entry from an IndexerEntry.
"""
@spec new(IndexerEntry.t()) :: t()
def new(%IndexerEntry{} = indexer_entry) do
%__MODULE__{
id: indexer_entry.id,
path: indexer_entry.path,
subject: indexer_entry.subject,
subtype: indexer_entry.subtype,
block_range: indexer_entry.block_range,
range: indexer_entry.range,
edit_range: indexer_entry.range
}
end
end
165 changes: 165 additions & 0 deletions apps/engine/lib/engine/code_mod/rename/file.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
defmodule Engine.CodeMod.Rename.File do
@moduledoc """
Handles file renaming logic during module renaming operations.

Determines if a file should be renamed when its containing module is renamed,
based on conventions and constraints.
"""
alias Engine.CodeMod.Rename.Entry
alias Engine.Search.Indexer
alias Forge.Ast
alias Forge.Document
alias Forge.ProcessCache
alias Forge.Project
alias Forge.Search.Indexer.Entry, as: IndexerEntry

@doc """
Determines if a file should be renamed when renaming a module.

Returns a `Forge.Document.Changes.RenameFile` struct if the file should be renamed,
or `nil` if no file rename is needed.
"""
@spec maybe_rename(Document.t(), Entry.t(), String.t()) :: Document.Changes.RenameFile.t() | nil
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{} = 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
[%IndexerEntry{} = root_module] ->
root_module.subject == entry.subject and root_module.block_range == entry.block_range

_ ->
false
end
end

defp rename_file(document, %Entry{} = entry, new_suffix) do
root_path = root_path()
relative_path = Path.relative_to(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 root_path do
Project.root_path(Engine.get_project())
end

defp fetch_new_name(document, %Entry{} = entry, new_suffix) do
text_edits = [Document.Edit.new(new_suffix, entry.edit_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.edit_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"}
segments =
case Path.split(path) do
["apps", app_name, "lib" | _] -> ["apps", app_name, "lib"]
["apps", app_name, "test" | _] -> ["apps", app_name, "test"]
["lib" | _] -> ["lib"]
["test" | _] -> ["test"]
_ -> nil
end

if segments do
{:ok, Path.join(segments)}
else
:error
end
end

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

phoenix_liveview_module?(subject) ->
"live"

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 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)
|> Path.join()
else
suffix
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

defp phoenix_component_module?(module) do
function_exists?(module, :__components__, 0) or
function_exists?(module, :__live__, 0)
end

defp function_exists?(module, function, arity) do
function_exported?(module, function, arity)
end
end
Loading