Skip to content

Commit

Permalink
Rename module base on Indexer
Browse files Browse the repository at this point in the history
We need to consider two typical scenarios:
one is renaming an exact entry,
and the other is renaming both the entry and its descendants.

I differentiate between these two scenarios based on the cursor's position.
If there are no more child modules after the cursor, for example,
in the case of `defmodule TopLevel.|Entry`, then rename the `Entry` module.

However, if there are child modules after the cursor,
then rename both the Entry module and its descendants.
For example, if the cursor is at `defmodule TopLevel.|Entry.Child`,
then `TopLevel.Entry.AnotherChild` will also be renamed.
  • Loading branch information
scottming committed Sep 17, 2023
1 parent cbe1fad commit eb2af3b
Show file tree
Hide file tree
Showing 10 changed files with 669 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# This file's contents are auto-generated. Do not edit.
defmodule Lexical.Protocol.Types.Rename.Params do
alias Lexical.Proto
alias Lexical.Protocol.Types
use Proto

deftype new_name: string(),
position: Types.Position,
text_document: Types.TextDocument.Identifier,
work_done_token: optional(Types.Progress.Token)
end
6 changes: 6 additions & 0 deletions apps/protocol/lib/lexical/protocol/requests.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ defmodule Lexical.Protocol.Requests do
defrequest "textDocument/hover", Types.Hover.Params
end

defmodule Rename do
use Proto

defrequest "textDocument/rename", Types.Rename.Params
end

# Server -> Client requests

defmodule RegisterCapability do
Expand Down
6 changes: 6 additions & 0 deletions apps/protocol/lib/lexical/protocol/responses.ex
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,11 @@ defmodule Lexical.Protocol.Responses do
defresponse optional(Types.Hover)
end

defmodule Rename do
use Proto

defresponse optional(Types.Workspace.Edit)
end

use Typespecs, for: :responses
end
8 changes: 8 additions & 0 deletions apps/remote_control/lib/lexical/remote_control/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ defmodule Lexical.RemoteControl.Api do
])
end

def rename(%Project{} = project, %Document{} = document, %Position{} = position, new_name) do
RemoteControl.call(project, CodeIntelligence.Rename, :rename, [
document,
position,
new_name
])
end

def complete(%Project{} = project, %Document{} = document, %Position{} = position) do
document_string = Document.to_string(document)
complete(project, document_string, position)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
defmodule Lexical.RemoteControl.CodeIntelligence.Entity do
alias Future.Code, as: Code
alias Lexical.Ast
alias Lexical.Document
alias Lexical.Document.Location
alias Lexical.Document.Position
alias Lexical.Document.Range
alias Lexical.Project
alias Lexical.RemoteControl
alias Lexical.Text

require Logger

@type resolved :: {:module, module()}

@doc """
Attempts to resolve the entity at the given position in the document.
## Return values
Returns `{:ok, resolved, range}` if successful and `{:error, error}`
otherwise. The `range` includes the resolved node and the
Resolved entities are one of:
* `{:module, module}`
"""
@spec resolve(Document.t(), Position.t()) :: {:ok, resolved, Range.t()} | {:error, term()}
def resolve(%Document{} = document, %Position{} = position) do
with {:ok, %{context: context, begin: begin_pos, end: end_pos}} <-
Ast.surround_context(document, position),
{:ok, resolved, {begin_pos, end_pos}} <-
resolve(context, {begin_pos, end_pos}, document, position) do
{:ok, resolved, to_range(document, begin_pos, end_pos)}
else
{:error, :surround_context} -> {:error, :not_found}
error -> error
end
end

defp resolve({:alias, charlist}, node_range, document, position) do
resolve_module(charlist, node_range, document, position)
end

defp resolve({:alias, {:local_or_var, prefix}, charlist}, node_range, document, position) do
resolve_module(prefix ++ [?.] ++ charlist, node_range, document, position)
end

defp resolve({:local_or_var, ~c"__MODULE__"}, node_range, document, position) do
resolve_module(~c"__MODULE__", node_range, document, position)
end

defp resolve(context, _node_range, _document, _position) do
unsupported_context(context)
end

defp unsupported_context(context) do
{:error, {:unsupported, context}}
end

# Modules on a single line, e.g. "Foo.Bar.Baz"
defp resolve_module(charlist, {{line, column}, {line, _}}, document, position)
when is_list(charlist) do
# Take only the segments at and before the cursor, e.g.
# Foo|.Bar.Baz -> Foo
# Foo.|Bar.Baz -> Foo.Bar
module_string =
charlist
|> Enum.with_index(column)
|> Enum.take_while(fn {char, column} ->
column < position.character or char != ?.
end)
|> Enum.map(&elem(&1, 0))
|> List.to_string()

expanded =
[module_string]
|> Module.concat()
|> Ast.expand_aliases(document, position)

with {:ok, module} <- expanded do
{:ok, {:module, module}, {{line, column}, {line, column + String.length(module_string)}}}
end
end

# Modules on multiple lines, e.g. "Foo.\n Bar.\n Baz"
# Since we no longer have formatting information at this point, we
# just return the entire module for now.
defp resolve_module(charlist, node_range, document, position) do
module_string = List.to_string(charlist)

expanded =
[module_string]
|> Module.concat()
|> Ast.expand_aliases(document, position)

with {:ok, module} <- expanded do
{:ok, {:module, module}, node_range}
end
end

@doc """
Returns the source location of the entity at the given position in the document.
"""
def definition(%Project{} = project, %Document{} = document, %Position{} = position) do
project
|> RemoteControl.Api.definition(document, position)
|> parse_location(document)
end

defp parse_location(%ElixirSense.Location{} = location, document) do
%{file: file, line: line, column: column} = location
file_path = file || document.path
uri = Document.Path.ensure_uri(file_path)

with {:ok, document} <- Document.Store.open_temporary(uri),
{:ok, text} <- Document.fetch_text_at(document, line) do
range = to_precise_range(document, text, line, column)

{:ok, Location.new(range, document)}
else
_ ->
{:error, "Could not open source file or fetch line text: #{inspect(file_path)}"}
end
end

defp parse_location(nil, _) do
{:ok, nil}
end

defp to_precise_range(%Document{} = document, text, line, column) do
case Code.Fragment.surround_context(text, {line, column}) do
%{begin: start_pos, end: end_pos} ->
to_range(document, start_pos, end_pos)

_ ->
# If the column is 1, but the code doesn't start on the first column, which isn't what we want.
# The cursor will be placed to the left of the actual definition.
column = if column == 1, do: Text.count_leading_spaces(text) + 1, else: column
pos = {line, column}
to_range(document, pos, pos)
end
end

defp to_range(%Document{} = document, {begin_line, begin_column}, {end_line, end_column}) do
Range.new(
Position.new(document, begin_line, begin_column),
Position.new(document, end_line, end_column)
)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
defmodule Lexical.RemoteControl.CodeIntelligence.Rename do
alias Lexical.Ast
alias Lexical.Document
alias Lexical.Document.Edit
alias Lexical.Document.Line
alias Lexical.Document.Position
alias Lexical.RemoteControl.CodeIntelligence.Entity
alias Lexical.RemoteControl.Search.Store
require Logger

import Line

@spec rename(Document.t(), Position.t(), String.t()) ::
{:ok, %{Lexical.uri() => [Edit.t()]}} | {:error, term()}
def rename(%Document{} = document, %Position{} = position, new_name) do
with {:ok, entity, range} <- resolve(document, position),
{:ok, results} <- search(document, position, entity, range) do
to_uri_with_changes(results, new_name)
end
end

defp resolve(document, position) do
case Entity.resolve(document, position) do
{:ok, {:module, module}, range} -> {:ok, module, range}
{:error, error} -> {:error, error}
end
end

defp search(document, position, entity, range) do
cursor_entity_string = cursor_entity_string(range)

if at_the_middle_of_module?(document, position, range) do
search_descendants(entity, cursor_entity_string)
else
search_exact(entity, cursor_entity_string)
end
end

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

case Ast.surround_context(document, position) do
{:ok, %{context: {:alias, alias}}} ->
String.length(range_text) < length(alias)

_ ->
false
end
end

defp cursor_entity_string(range) do
# Parent.|Module -> Module
range
|> range_text()
|> String.split(".")
|> List.last()
end

defp search_descendants(entity, cursor_entity_string) do
entity_string = inspect(entity)

case Store.fuzzy(entity_string, subject: [:definition, :reference]) do
{:ok, results} ->
filtered =
results
|> Enum.filter(fn result ->
candidate = inspect(result.subject)
entity? = candidate == entity_string
# e.g: `Parent.Module.GrandChild` is child of `Parent.Module`
child_of_entity? = String.starts_with?(candidate, "#{entity_string}.")

(entity? or child_of_entity?) and
contains_cursor_entity?(result.range, cursor_entity_string)
end)
|> adjust_range(entity)

{:ok, filtered}

other ->
other
end
end

defp search_exact(entity, cursor_entity_string) do
entity_string = inspect(entity)

case Store.exact(entity_string, subject: [:definition, :reference]) do
{:ok, results} ->
filtered =
Enum.filter(results, fn result ->
contains_cursor_entity?(result.range, cursor_entity_string)
end)

{:ok, filtered}

other ->
other
end
end

defp adjust_range(entries, entity) do
for entry <- entries do
location = {entry.range.start.line, entry.range.start.character}
uri = Document.Path.ensure_uri(entry.path)

case resolve_entity_range(uri, location, entity) do
{:ok, range} ->
%{entry | range: range}

:error ->
:error
end
end
|> Enum.reject(&(&1 == :error))
end

defp resolve_entity_range(uri, location, entity) do
{line, character} = location

with {:ok, document} <- Document.Store.open_temporary(uri),
position = Position.new(document, line, character),
{:ok, result, range} <- resolve(document, position) do
if result == entity do
{:ok, range}
else
result_length = result |> inspect() |> String.length()
# Move the cursor the next part:
# `|Parent.Next.Target.Child` -> 'Parent.|Next.Target.Child' -> 'Parent.Next.|Target.Child'
resolve_entity_range(uri, {line, character + result_length + 1}, entity)
end
else
_ ->
Logger.error("Failed to find entity range for #{inspect(uri)} at #{inspect(location)}")
:error
end
end

defp contains_cursor_entity?(range, cursor_entity_string) do
# an entity might be aliased
range |> range_text() |> String.contains?(cursor_entity_string)
end

defp to_uri_with_changes(results, new_name) do
{:ok,
Enum.group_by(
results,
fn result -> Document.Path.ensure_uri(result.path) end,
fn result ->
cursor_entity_length = result.range |> cursor_entity_string() |> String.length()
# e.g: `Parent.|ToBeRenameModule`, we need the start position of `ToBeRenameModule`
start_character = result.range.end.character - cursor_entity_length
start_position = %{result.range.start | character: start_character}

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

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
end
Loading

0 comments on commit eb2af3b

Please sign in to comment.