forked from elixir-lsp/elixir-ls
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
LSPs can offer a rename capability to rename a "symbol" across a workspace. We're not there yet, but this is a starting point. In VS Code it can be used by right clicking a symbol (currently variable or call to local function) and selecting "Rename symbol". A dialog should pop up asking for a new name. Official spec: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rename
- Loading branch information
Showing
5 changed files
with
421 additions
and
0 deletions.
There are no files selected for viewing
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
100 changes: 100 additions & 0 deletions
100
apps/language_server/lib/language_server/providers/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,100 @@ | ||
defmodule ElixirLS.LanguageServer.Providers.Rename do | ||
@moduledoc """ | ||
Provides functionality to rename a symbol inside a workspace | ||
https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rename | ||
""" | ||
|
||
alias ElixirLS.LanguageServer.SourceFile | ||
#import ElixirLS.LanguageServer.Protocol | ||
|
||
def rename(%SourceFile{} = source_file, start_uri, line, character, new_name) do | ||
edits = | ||
with %{context: {context, char_ident}} when context in [:local_or_var, :local_call] <- | ||
Code.Fragment.surround_context(source_file.text, {line, character}), | ||
%ElixirSense.Location{} = definition <- | ||
ElixirSense.definition(source_file.text, line, character), | ||
references <- ElixirSense.references(source_file.text, line, character) do | ||
|
||
|
||
length_old = length(char_ident) | ||
|
||
[ | ||
%{ | ||
uri: start_uri, | ||
range: | ||
adjust_range( | ||
definition.line, | ||
definition.column, | ||
definition.line, | ||
definition.column + length_old | ||
) | ||
} | ||
| repack_references(references, start_uri) | ||
] | ||
else | ||
_ -> | ||
[] | ||
end | ||
|
||
changes = | ||
edits | ||
|> Enum.group_by(& &1.uri) | ||
|> Enum.map(fn {uri, edits} -> | ||
%{ | ||
"textDocument" => %{ | ||
"uri" => uri, | ||
"version" => source_file.version + 1 | ||
}, | ||
"edits" => | ||
Enum.map(edits, fn edit -> | ||
%{"range" => edit.range, "newText" => new_name} | ||
end) | ||
} | ||
end) | ||
|
||
{:ok, %{"documentChanges" => changes}} | ||
end | ||
|
||
def prepare(%SourceFile{} = source_file, _uri, line, character) do | ||
result = | ||
with %{ | ||
begin: {start_line, start_col}, | ||
end: {end_line, end_col}, | ||
context: {context, char_ident} | ||
} when context in [:local_or_var, :local_call] <- Code.Fragment.surround_context(source_file.text, {line, character}) do | ||
%{ | ||
range: adjust_range(start_line, start_col, end_line, end_col), | ||
placeholder: to_string(char_ident) | ||
} | ||
else | ||
_ -> | ||
# Not a variable or local call, skipping for now | ||
nil | ||
end | ||
|
||
{:ok, result} | ||
end | ||
|
||
defp repack_references(references, uri) do | ||
for reference <- references do | ||
%{ | ||
uri: uri, | ||
range: %{ | ||
end: %{character: reference.range.end.column - 1, line: reference.range.end.line - 1}, | ||
start: %{ | ||
character: reference.range.start.column - 1, | ||
line: reference.range.start.line - 1 | ||
} | ||
} | ||
} | ||
end | ||
end | ||
|
||
defp adjust_range(start_line, start_character, end_line, end_character) do | ||
%{ | ||
start: %{line: start_line - 1, character: start_character - 1}, | ||
end: %{line: end_line - 1, character: end_character - 1} | ||
} | ||
end | ||
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,213 @@ | ||
defmodule ElixirLS.LanguageServer.Providers.RenameTest do | ||
use ExUnit.Case, async: true | ||
|
||
alias ElixirLS.LanguageServer.Providers.Rename | ||
alias ElixirLS.LanguageServer.SourceFile | ||
alias ElixirLS.LanguageServer.Test.FixtureHelpers | ||
# mix cmd --app language_server mix test test/providers/rename_test.exs | ||
|
||
@fake_uri "file:///World/Netherlands/Amsterdam/supercomputer/amazing.ex" | ||
|
||
test "rename blank space" do | ||
text = """ | ||
defmodule MyModule do | ||
def hello() do | ||
IO.inspect("hello world") | ||
end | ||
end | ||
""" | ||
|
||
{line, char} = {2, 1} | ||
|
||
assert {:ok, %{"documentChanges" => []}} = | ||
Rename.rename(%SourceFile{text: text, version: 0}, @fake_uri, line, char, "test") | ||
end | ||
|
||
describe "renaming variable" do | ||
test "a -> test" do | ||
text = """ | ||
defmodule MyModule do | ||
def add(a, b) do | ||
a + b | ||
end | ||
end | ||
""" | ||
|
||
# _a + b | ||
{line, char} = {3, 5} | ||
|
||
assert {:ok, %{"documentChanges" => changes}} = | ||
Rename.rename(%SourceFile{text: text, version: 0}, @fake_uri, line, char, "test") | ||
|
||
assert %{ | ||
"textDocument" => %{ | ||
"uri" => @fake_uri, | ||
"version" => 1 | ||
}, | ||
"edits" => [ | ||
%{ | ||
"range" => %{end: %{character: 11, line: 1}, start: %{character: 10, line: 1}}, | ||
"newText" => "test" | ||
}, | ||
%{ | ||
"range" => %{end: %{character: 5, line: 2}, start: %{character: 4, line: 2}}, | ||
"newText" => "test" | ||
} | ||
] | ||
} == List.first(changes) | ||
end | ||
|
||
test "nema -> name" do | ||
text = """ | ||
defmodule MyModule do | ||
def hello(nema) do | ||
"Hello " <> nema | ||
end | ||
end | ||
""" | ||
|
||
# "Hello " <> ne_ma | ||
{line, char} = {3, 19} | ||
|
||
assert {:ok, %{"documentChanges" => [changes]}} = | ||
Rename.rename( | ||
%SourceFile{text: text, version: 0}, | ||
@fake_uri, | ||
line, | ||
char, | ||
"name" | ||
) | ||
|
||
assert %{ | ||
"textDocument" => %{ | ||
"uri" => @fake_uri, | ||
"version" => 1 | ||
}, | ||
"edits" => [ | ||
%{ | ||
"range" => %{end: %{character: 16, line: 1}, start: %{character: 12, line: 1}}, | ||
"newText" => "name" | ||
}, | ||
%{ | ||
"range" => %{end: %{character: 20, line: 2}, start: %{character: 16, line: 2}}, | ||
"newText" => "name" | ||
} | ||
] | ||
} == changes | ||
end | ||
end | ||
|
||
describe "renaming local function" do | ||
test "create_message -> store_message" do | ||
file_path = FixtureHelpers.get_path("rename_example.exs") | ||
text = File.read!(file_path) | ||
uri = SourceFile.path_to_uri(file_path) | ||
|
||
# |> _create_message | ||
{line, char} = {28, 8} | ||
|
||
assert {:ok, %{"documentChanges" => [changes]}} = | ||
Rename.rename( | ||
%SourceFile{text: text, version: 0}, | ||
uri, | ||
line, | ||
char, | ||
"store_message" | ||
) | ||
|
||
assert %{ | ||
"textDocument" => %{ | ||
"uri" => uri, | ||
"version" => 1 | ||
}, | ||
"edits" => [ | ||
%{ | ||
"newText" => "store_message", | ||
"range" => %{end: %{character: 21, line: 43}, start: %{character: 7, line: 43}} | ||
}, | ||
%{ | ||
"newText" => "store_message", | ||
"range" => %{end: %{character: 21, line: 27}, start: %{character: 7, line: 27}} | ||
} | ||
] | ||
} == changes | ||
end | ||
end | ||
|
||
describe "not yet (fully) supported/working renaming cases" do | ||
test "rename started with cursor at function definition" do | ||
file_path = FixtureHelpers.get_path("rename_example.exs") | ||
text = File.read!(file_path) | ||
uri = SourceFile.path_to_uri(file_path) | ||
|
||
# defp _handle_error({:ok, message}) | ||
{line, char} = {4, 8} | ||
|
||
assert {:ok, %{"documentChanges" => changes}} = | ||
Rename.rename( | ||
%SourceFile{text: text, version: 0}, | ||
uri, | ||
line, | ||
char, | ||
"handle_errors" | ||
) | ||
|
||
refute %{ | ||
"textDocument" => %{ | ||
"uri" => uri, | ||
"version" => 1 | ||
}, | ||
"edits" => [ | ||
%{ | ||
"newText" => "handle_errors", | ||
"range" => %{end: %{character: 19, line: 37}, start: %{character: 7, line: 37}} | ||
}, | ||
%{ | ||
"newText" => "handle_errors", | ||
"range" => %{end: %{character: 19, line: 39}, start: %{character: 7, line: 39}} | ||
}, | ||
%{ | ||
"newText" => "handle_errors", | ||
"range" => %{end: %{character: 19, line: 28}, start: %{character: 7, line: 28}} | ||
} | ||
] | ||
} == List.first(changes) | ||
end | ||
|
||
test "rename function with multiple heads" do | ||
file_path = FixtureHelpers.get_path("rename_example.exs") | ||
text = File.read!(file_path) | ||
uri = SourceFile.path_to_uri(file_path) | ||
|
||
# |> _handle_error | ||
{line, char} = {29, 8} | ||
|
||
assert {:ok, %{"documentChanges" => [changes]}} = | ||
Rename.rename( | ||
%SourceFile{text: text, version: 0}, | ||
uri, | ||
line, | ||
char, | ||
"handle_errors" | ||
) | ||
|
||
# missed second function head on line 40: handle_error({:error, changeset}) | ||
assert %{ | ||
"textDocument" => %{ | ||
"uri" => uri, | ||
"version" => 1 | ||
}, | ||
"edits" => [ | ||
%{ | ||
"newText" => "handle_errors", | ||
"range" => %{end: %{character: 19, line: 37}, start: %{character: 7, line: 37}} | ||
}, | ||
%{ | ||
"newText" => "handle_errors", | ||
"range" => %{end: %{character: 19, line: 28}, start: %{character: 7, line: 28}} | ||
} | ||
] | ||
} == changes | ||
end | ||
end | ||
end |
Oops, something went wrong.