Skip to content

Commit

Permalink
POC for "rename" functionality
Browse files Browse the repository at this point in the history
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
Tuxified committed Feb 11, 2022
1 parent 8e92bdc commit 8945480
Show file tree
Hide file tree
Showing 5 changed files with 421 additions and 0 deletions.
19 changes: 19 additions & 0 deletions apps/language_server/lib/language_server/protocol.ex
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,25 @@ defmodule ElixirLS.LanguageServer.Protocol do
end
end

defmacro rename_req(id, uri, line, character, new_name) do
quote do
request(unquote(id), "textDocument/rename", %{
"textDocument" => %{"uri" => unquote(uri)},
"position" => %{"line" => unquote(line), "character" => unquote(character)},
"newName" => unquote(new_name)
})
end
end

defmacro prepare_rename_req(id, uri, line, character) do
quote do
request(unquote(id), "textDocument/prepareRename", %{
"textDocument" => %{"uri" => unquote(uri)},
"position" => %{"line" => unquote(line), "character" => unquote(character)}
})
end
end

defmacro execute_command_req(id, command, arguments) do
quote do
request(unquote(id), "workspace/executeCommand", %{
Expand Down
100 changes: 100 additions & 0 deletions apps/language_server/lib/language_server/providers/rename.ex
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
22 changes: 22 additions & 0 deletions apps/language_server/lib/language_server/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ defmodule ElixirLS.LanguageServer.Server do
Definition,
Implementation,
References,
Rename,
Formatting,
SignatureHelp,
DocumentSymbols,
Expand Down Expand Up @@ -739,6 +740,26 @@ defmodule ElixirLS.LanguageServer.Server do
{:async, fun, state}
end

defp handle_request(rename_req(_id, uri, line, character, new_name), state = %__MODULE__{}) do
source_file = get_source_file(state, uri)

fun = fn ->
Rename.rename(source_file, uri, line + 1, character + 1, new_name)
end

{:async, fun, state}
end

defp handle_request(prepare_rename_req(_id, uri, line, character), state = %__MODULE__{}) do
source_file = get_source_file(state, uri)

fun = fn ->
Rename.prepare(source_file, uri, line + 1, character + 1)
end

{:async, fun, state}
end

defp handle_request(execute_command_req(_id, command, args) = req, state = %__MODULE__{}) do
{:async,
fn ->
Expand Down Expand Up @@ -809,6 +830,7 @@ defmodule ElixirLS.LanguageServer.Server do
"workspaceSymbolProvider" => true,
"documentOnTypeFormattingProvider" => %{"firstTriggerCharacter" => "\n"},
"codeLensProvider" => %{"resolveProvider" => false},
"renameProvider" => %{"prepareProvider" => true},
"executeCommandProvider" => %{
"commands" => ExecuteCommand.get_commands(server_instance_id)
},
Expand Down
213 changes: 213 additions & 0 deletions apps/language_server/test/providers/rename_test.exs
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
Loading

0 comments on commit 8945480

Please sign in to comment.