Skip to content

Commit 8945480

Browse files
committed
POC for "rename" functionality
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
1 parent 8e92bdc commit 8945480

File tree

5 files changed

+421
-0
lines changed

5 files changed

+421
-0
lines changed

apps/language_server/lib/language_server/protocol.ex

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,25 @@ defmodule ElixirLS.LanguageServer.Protocol do
181181
end
182182
end
183183

184+
defmacro rename_req(id, uri, line, character, new_name) do
185+
quote do
186+
request(unquote(id), "textDocument/rename", %{
187+
"textDocument" => %{"uri" => unquote(uri)},
188+
"position" => %{"line" => unquote(line), "character" => unquote(character)},
189+
"newName" => unquote(new_name)
190+
})
191+
end
192+
end
193+
194+
defmacro prepare_rename_req(id, uri, line, character) do
195+
quote do
196+
request(unquote(id), "textDocument/prepareRename", %{
197+
"textDocument" => %{"uri" => unquote(uri)},
198+
"position" => %{"line" => unquote(line), "character" => unquote(character)}
199+
})
200+
end
201+
end
202+
184203
defmacro execute_command_req(id, command, arguments) do
185204
quote do
186205
request(unquote(id), "workspace/executeCommand", %{
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
defmodule ElixirLS.LanguageServer.Providers.Rename do
2+
@moduledoc """
3+
Provides functionality to rename a symbol inside a workspace
4+
5+
https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rename
6+
"""
7+
8+
alias ElixirLS.LanguageServer.SourceFile
9+
#import ElixirLS.LanguageServer.Protocol
10+
11+
def rename(%SourceFile{} = source_file, start_uri, line, character, new_name) do
12+
edits =
13+
with %{context: {context, char_ident}} when context in [:local_or_var, :local_call] <-
14+
Code.Fragment.surround_context(source_file.text, {line, character}),
15+
%ElixirSense.Location{} = definition <-
16+
ElixirSense.definition(source_file.text, line, character),
17+
references <- ElixirSense.references(source_file.text, line, character) do
18+
19+
20+
length_old = length(char_ident)
21+
22+
[
23+
%{
24+
uri: start_uri,
25+
range:
26+
adjust_range(
27+
definition.line,
28+
definition.column,
29+
definition.line,
30+
definition.column + length_old
31+
)
32+
}
33+
| repack_references(references, start_uri)
34+
]
35+
else
36+
_ ->
37+
[]
38+
end
39+
40+
changes =
41+
edits
42+
|> Enum.group_by(& &1.uri)
43+
|> Enum.map(fn {uri, edits} ->
44+
%{
45+
"textDocument" => %{
46+
"uri" => uri,
47+
"version" => source_file.version + 1
48+
},
49+
"edits" =>
50+
Enum.map(edits, fn edit ->
51+
%{"range" => edit.range, "newText" => new_name}
52+
end)
53+
}
54+
end)
55+
56+
{:ok, %{"documentChanges" => changes}}
57+
end
58+
59+
def prepare(%SourceFile{} = source_file, _uri, line, character) do
60+
result =
61+
with %{
62+
begin: {start_line, start_col},
63+
end: {end_line, end_col},
64+
context: {context, char_ident}
65+
} when context in [:local_or_var, :local_call] <- Code.Fragment.surround_context(source_file.text, {line, character}) do
66+
%{
67+
range: adjust_range(start_line, start_col, end_line, end_col),
68+
placeholder: to_string(char_ident)
69+
}
70+
else
71+
_ ->
72+
# Not a variable or local call, skipping for now
73+
nil
74+
end
75+
76+
{:ok, result}
77+
end
78+
79+
defp repack_references(references, uri) do
80+
for reference <- references do
81+
%{
82+
uri: uri,
83+
range: %{
84+
end: %{character: reference.range.end.column - 1, line: reference.range.end.line - 1},
85+
start: %{
86+
character: reference.range.start.column - 1,
87+
line: reference.range.start.line - 1
88+
}
89+
}
90+
}
91+
end
92+
end
93+
94+
defp adjust_range(start_line, start_character, end_line, end_character) do
95+
%{
96+
start: %{line: start_line - 1, character: start_character - 1},
97+
end: %{line: end_line - 1, character: end_character - 1}
98+
}
99+
end
100+
end

apps/language_server/lib/language_server/server.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ defmodule ElixirLS.LanguageServer.Server do
2424
Definition,
2525
Implementation,
2626
References,
27+
Rename,
2728
Formatting,
2829
SignatureHelp,
2930
DocumentSymbols,
@@ -739,6 +740,26 @@ defmodule ElixirLS.LanguageServer.Server do
739740
{:async, fun, state}
740741
end
741742

743+
defp handle_request(rename_req(_id, uri, line, character, new_name), state = %__MODULE__{}) do
744+
source_file = get_source_file(state, uri)
745+
746+
fun = fn ->
747+
Rename.rename(source_file, uri, line + 1, character + 1, new_name)
748+
end
749+
750+
{:async, fun, state}
751+
end
752+
753+
defp handle_request(prepare_rename_req(_id, uri, line, character), state = %__MODULE__{}) do
754+
source_file = get_source_file(state, uri)
755+
756+
fun = fn ->
757+
Rename.prepare(source_file, uri, line + 1, character + 1)
758+
end
759+
760+
{:async, fun, state}
761+
end
762+
742763
defp handle_request(execute_command_req(_id, command, args) = req, state = %__MODULE__{}) do
743764
{:async,
744765
fn ->
@@ -809,6 +830,7 @@ defmodule ElixirLS.LanguageServer.Server do
809830
"workspaceSymbolProvider" => true,
810831
"documentOnTypeFormattingProvider" => %{"firstTriggerCharacter" => "\n"},
811832
"codeLensProvider" => %{"resolveProvider" => false},
833+
"renameProvider" => %{"prepareProvider" => true},
812834
"executeCommandProvider" => %{
813835
"commands" => ExecuteCommand.get_commands(server_instance_id)
814836
},
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
defmodule ElixirLS.LanguageServer.Providers.RenameTest do
2+
use ExUnit.Case, async: true
3+
4+
alias ElixirLS.LanguageServer.Providers.Rename
5+
alias ElixirLS.LanguageServer.SourceFile
6+
alias ElixirLS.LanguageServer.Test.FixtureHelpers
7+
# mix cmd --app language_server mix test test/providers/rename_test.exs
8+
9+
@fake_uri "file:///World/Netherlands/Amsterdam/supercomputer/amazing.ex"
10+
11+
test "rename blank space" do
12+
text = """
13+
defmodule MyModule do
14+
def hello() do
15+
IO.inspect("hello world")
16+
end
17+
end
18+
"""
19+
20+
{line, char} = {2, 1}
21+
22+
assert {:ok, %{"documentChanges" => []}} =
23+
Rename.rename(%SourceFile{text: text, version: 0}, @fake_uri, line, char, "test")
24+
end
25+
26+
describe "renaming variable" do
27+
test "a -> test" do
28+
text = """
29+
defmodule MyModule do
30+
def add(a, b) do
31+
a + b
32+
end
33+
end
34+
"""
35+
36+
# _a + b
37+
{line, char} = {3, 5}
38+
39+
assert {:ok, %{"documentChanges" => changes}} =
40+
Rename.rename(%SourceFile{text: text, version: 0}, @fake_uri, line, char, "test")
41+
42+
assert %{
43+
"textDocument" => %{
44+
"uri" => @fake_uri,
45+
"version" => 1
46+
},
47+
"edits" => [
48+
%{
49+
"range" => %{end: %{character: 11, line: 1}, start: %{character: 10, line: 1}},
50+
"newText" => "test"
51+
},
52+
%{
53+
"range" => %{end: %{character: 5, line: 2}, start: %{character: 4, line: 2}},
54+
"newText" => "test"
55+
}
56+
]
57+
} == List.first(changes)
58+
end
59+
60+
test "nema -> name" do
61+
text = """
62+
defmodule MyModule do
63+
def hello(nema) do
64+
"Hello " <> nema
65+
end
66+
end
67+
"""
68+
69+
# "Hello " <> ne_ma
70+
{line, char} = {3, 19}
71+
72+
assert {:ok, %{"documentChanges" => [changes]}} =
73+
Rename.rename(
74+
%SourceFile{text: text, version: 0},
75+
@fake_uri,
76+
line,
77+
char,
78+
"name"
79+
)
80+
81+
assert %{
82+
"textDocument" => %{
83+
"uri" => @fake_uri,
84+
"version" => 1
85+
},
86+
"edits" => [
87+
%{
88+
"range" => %{end: %{character: 16, line: 1}, start: %{character: 12, line: 1}},
89+
"newText" => "name"
90+
},
91+
%{
92+
"range" => %{end: %{character: 20, line: 2}, start: %{character: 16, line: 2}},
93+
"newText" => "name"
94+
}
95+
]
96+
} == changes
97+
end
98+
end
99+
100+
describe "renaming local function" do
101+
test "create_message -> store_message" do
102+
file_path = FixtureHelpers.get_path("rename_example.exs")
103+
text = File.read!(file_path)
104+
uri = SourceFile.path_to_uri(file_path)
105+
106+
# |> _create_message
107+
{line, char} = {28, 8}
108+
109+
assert {:ok, %{"documentChanges" => [changes]}} =
110+
Rename.rename(
111+
%SourceFile{text: text, version: 0},
112+
uri,
113+
line,
114+
char,
115+
"store_message"
116+
)
117+
118+
assert %{
119+
"textDocument" => %{
120+
"uri" => uri,
121+
"version" => 1
122+
},
123+
"edits" => [
124+
%{
125+
"newText" => "store_message",
126+
"range" => %{end: %{character: 21, line: 43}, start: %{character: 7, line: 43}}
127+
},
128+
%{
129+
"newText" => "store_message",
130+
"range" => %{end: %{character: 21, line: 27}, start: %{character: 7, line: 27}}
131+
}
132+
]
133+
} == changes
134+
end
135+
end
136+
137+
describe "not yet (fully) supported/working renaming cases" do
138+
test "rename started with cursor at function definition" do
139+
file_path = FixtureHelpers.get_path("rename_example.exs")
140+
text = File.read!(file_path)
141+
uri = SourceFile.path_to_uri(file_path)
142+
143+
# defp _handle_error({:ok, message})
144+
{line, char} = {4, 8}
145+
146+
assert {:ok, %{"documentChanges" => changes}} =
147+
Rename.rename(
148+
%SourceFile{text: text, version: 0},
149+
uri,
150+
line,
151+
char,
152+
"handle_errors"
153+
)
154+
155+
refute %{
156+
"textDocument" => %{
157+
"uri" => uri,
158+
"version" => 1
159+
},
160+
"edits" => [
161+
%{
162+
"newText" => "handle_errors",
163+
"range" => %{end: %{character: 19, line: 37}, start: %{character: 7, line: 37}}
164+
},
165+
%{
166+
"newText" => "handle_errors",
167+
"range" => %{end: %{character: 19, line: 39}, start: %{character: 7, line: 39}}
168+
},
169+
%{
170+
"newText" => "handle_errors",
171+
"range" => %{end: %{character: 19, line: 28}, start: %{character: 7, line: 28}}
172+
}
173+
]
174+
} == List.first(changes)
175+
end
176+
177+
test "rename function with multiple heads" do
178+
file_path = FixtureHelpers.get_path("rename_example.exs")
179+
text = File.read!(file_path)
180+
uri = SourceFile.path_to_uri(file_path)
181+
182+
# |> _handle_error
183+
{line, char} = {29, 8}
184+
185+
assert {:ok, %{"documentChanges" => [changes]}} =
186+
Rename.rename(
187+
%SourceFile{text: text, version: 0},
188+
uri,
189+
line,
190+
char,
191+
"handle_errors"
192+
)
193+
194+
# missed second function head on line 40: handle_error({:error, changeset})
195+
assert %{
196+
"textDocument" => %{
197+
"uri" => uri,
198+
"version" => 1
199+
},
200+
"edits" => [
201+
%{
202+
"newText" => "handle_errors",
203+
"range" => %{end: %{character: 19, line: 37}, start: %{character: 7, line: 37}}
204+
},
205+
%{
206+
"newText" => "handle_errors",
207+
"range" => %{end: %{character: 19, line: 28}, start: %{character: 7, line: 28}}
208+
}
209+
]
210+
} == changes
211+
end
212+
end
213+
end

0 commit comments

Comments
 (0)