-
-
Notifications
You must be signed in to change notification settings - Fork 83
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
10 changed files
with
669 additions
and
0 deletions.
There are no files selected for viewing
11 changes: 11 additions & 0 deletions
11
apps/protocol/lib/generated/lexical/protocol/types/rename/params.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
151 changes: 151 additions & 0 deletions
151
apps/remote_control/lib/lexical/remote_control/code_intelligence/entity.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
164 changes: 164 additions & 0 deletions
164
apps/remote_control/lib/lexical/remote_control/code_intelligence/rename.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.