Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rename file when renaming module #651

Merged
merged 12 commits into from
Apr 26, 2024
Merged
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
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
scottming marked this conversation as resolved.
Show resolved Hide resolved

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
scottming marked this conversation as resolved.
Show resolved Hide resolved
scohen marked this conversation as resolved.
Show resolved Hide resolved
# 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
Comment on lines +82 to +89
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this worries me; all of these prefixes can be changed in the mix.exs. To get these right you need to interrogate the project config.

Copy link
Collaborator Author

@scottming scottming Apr 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you referring to the apps_path of an umbrella project and the elixirc_paths of a regular project? If so, what kind of interrogation should I conduct?

#
# 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