Skip to content

Commit 6f2f071

Browse files
authored
Merge pull request #776 from Qarma-inspect/replace-unknown-function
Replace unknown remote function
2 parents e260665 + b92e07a commit 6f2f071

File tree

7 files changed

+588
-24
lines changed

7 files changed

+588
-24
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceRemoteFunction do
2+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast
3+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff
4+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Text
5+
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit
6+
7+
@spec text_edits(String.t(), Ast.t(), [[atom()]], atom(), atom()) ::
8+
{:ok, [TextEdit.t()]} | :error
9+
def text_edits(original_text, ast, possible_aliases, name, suggestion) do
10+
with {:ok, transformed} <-
11+
apply_transforms(original_text, ast, possible_aliases, name, suggestion) do
12+
{:ok, Diff.diff(original_text, transformed)}
13+
end
14+
end
15+
16+
defp apply_transforms(line_text, quoted_ast, possible_aliases, name, suggestion) do
17+
leading_indent = Text.leading_indent(line_text)
18+
19+
updated_ast =
20+
Macro.postwalk(quoted_ast, fn
21+
{:., function_meta, [{:__aliases__, module_meta, module_alias}, ^name]} ->
22+
if module_alias in possible_aliases do
23+
{:., function_meta, [{:__aliases__, module_meta, module_alias}, suggestion]}
24+
else
25+
{:., function_meta, [{:__aliases__, module_meta, module_alias}, name]}
26+
end
27+
28+
other ->
29+
other
30+
end)
31+
32+
if updated_ast != quoted_ast do
33+
updated_ast
34+
|> Ast.to_string()
35+
# We're dealing with a single error on a single line.
36+
# If the line doesn't compile (like it has a do with no end), ElixirSense
37+
# adds additional lines do documents with errors, so take the first line, as it's
38+
# the properly transformed source
39+
|> Text.fetch_line(0)
40+
|> case do
41+
{:ok, text} ->
42+
{:ok, "#{leading_indent}#{text}"}
43+
44+
error ->
45+
error
46+
end
47+
else
48+
:error
49+
end
50+
end
51+
end

apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do
22
alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast
33
alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff
4+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Text
45
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit
56

67
@spec text_edits(String.t(), Ast.t(), String.t() | atom) :: {:ok, [TextEdit.t()]} | :error
@@ -28,7 +29,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do
2829

2930
defp apply_transform(line_text, quoted_ast, unused_variable_name) do
3031
underscored_variable_name = :"_#{unused_variable_name}"
31-
leading_indent = leading_indent(line_text)
32+
leading_indent = Text.leading_indent(line_text)
3233

3334
Macro.postwalk(quoted_ast, fn
3435
{^unused_variable_name, meta, context} ->
@@ -42,7 +43,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do
4243
# If the line doesn't compile (like it has a do with no end), ElixirSense
4344
# adds additional lines do documents with errors, so take the first line, as it's
4445
# the properly transformed source
45-
|> fetch_line(0)
46+
|> Text.fetch_line(0)
4647
|> case do
4748
{:ok, text} ->
4849
{:ok, "#{leading_indent}#{text}"}
@@ -51,24 +52,4 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do
5152
error
5253
end
5354
end
54-
55-
@indent_regex ~r/^\s+/
56-
defp leading_indent(line_text) do
57-
case Regex.scan(@indent_regex, line_text) do
58-
[indent] -> indent
59-
_ -> ""
60-
end
61-
end
62-
63-
defp fetch_line(message, line_number) do
64-
line =
65-
message
66-
|> String.split(["\r\n", "\r", "\n"])
67-
|> Enum.at(line_number)
68-
69-
case line do
70-
nil -> :error
71-
other -> {:ok, other}
72-
end
73-
end
7455
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Text do
2+
@indent_regex ~r/^\s+/
3+
def leading_indent(line_text) do
4+
case Regex.scan(@indent_regex, line_text) do
5+
[indent] -> indent
6+
_ -> ""
7+
end
8+
end
9+
10+
def fetch_line(message, line_number) do
11+
line =
12+
message
13+
|> String.split(["\r\n", "\r", "\n"])
14+
|> Enum.at(line_number)
15+
16+
case line do
17+
nil -> :error
18+
other -> {:ok, other}
19+
end
20+
end
21+
end
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceRemoteFunction do
2+
@moduledoc """
3+
Code actions that replace unknown remote function with ones suggested by the warning message
4+
"""
5+
6+
alias ElixirLS.LanguageServer.Experimental.CodeMod
7+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast
8+
alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction
9+
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction, as: CodeActionResult
10+
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Diagnostic
11+
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit
12+
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Workspace
13+
alias ElixirLS.LanguageServer.Experimental.SourceFile
14+
alias ElixirSense.Core.Parser
15+
16+
@function_re ~r/(.*)\/(.*) is undefined or private. .*:\n(.*)/s
17+
18+
@spec apply(CodeAction.t()) :: [CodeActionResult.t()]
19+
def apply(%CodeAction{} = code_action) do
20+
source_file = code_action.source_file
21+
diagnostics = get_in(code_action, [:context, :diagnostics]) || []
22+
23+
diagnostics
24+
|> Enum.flat_map(fn %Diagnostic{} = diagnostic ->
25+
one_based_line = extract_start_line(diagnostic)
26+
suggestions = extract_suggestions(diagnostic.message)
27+
28+
with {:ok, module_alias, name} <- extract_function(diagnostic.message),
29+
{:ok, replies} <-
30+
build_code_actions(source_file, one_based_line, module_alias, name, suggestions) do
31+
replies
32+
else
33+
_ -> []
34+
end
35+
end)
36+
end
37+
38+
defp extract_function(message) do
39+
case Regex.scan(@function_re, message) do
40+
[[_, full_name, _, _]] ->
41+
{module_alias, name} = separate_module_from_name(full_name)
42+
{:ok, module_alias, name}
43+
44+
_ ->
45+
:error
46+
end
47+
end
48+
49+
defp separate_module_from_name(full_name) do
50+
{name, module_alias} =
51+
full_name
52+
|> String.split(".")
53+
|> Enum.map(&String.to_atom/1)
54+
|> List.pop_at(-1)
55+
56+
{module_alias, name}
57+
end
58+
59+
@suggestion_re ~r/\* .*\/[\d]+/
60+
defp extract_suggestions(message) do
61+
case Regex.scan(@function_re, message) do
62+
[[_, _, arity, suggestions_string]] ->
63+
@suggestion_re
64+
|> Regex.scan(suggestions_string)
65+
|> Enum.flat_map(fn [suggestion] ->
66+
case String.split(suggestion, [" ", "/"]) do
67+
["*", name, ^arity] -> [String.to_atom(name)]
68+
_ -> []
69+
end
70+
end)
71+
72+
_ ->
73+
[]
74+
end
75+
end
76+
77+
defp extract_start_line(%Diagnostic{} = diagnostic) do
78+
diagnostic.range.start.line
79+
end
80+
81+
defp build_code_actions(
82+
%SourceFile{} = source_file,
83+
one_based_line,
84+
module_alias,
85+
name,
86+
suggestions
87+
) do
88+
with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line),
89+
{:ok, line_ast} <- Ast.from(line_text),
90+
{:ok, possible_aliases} <-
91+
fetch_possible_aliases(source_file, one_based_line, module_alias),
92+
{:ok, edits_per_suggestion} <-
93+
text_edits_per_suggestion(line_text, line_ast, possible_aliases, name, suggestions) do
94+
case edits_per_suggestion do
95+
[] ->
96+
:error
97+
98+
[_ | _] ->
99+
replies =
100+
Enum.map(edits_per_suggestion, fn {text_edits, suggestion} ->
101+
text_edits = Enum.map(text_edits, &update_line(&1, one_based_line))
102+
103+
CodeActionResult.new(
104+
title: construct_title(module_alias, suggestion),
105+
kind: :quick_fix,
106+
edit: Workspace.Edit.new(changes: %{source_file.uri => text_edits})
107+
)
108+
end)
109+
110+
{:ok, replies}
111+
end
112+
end
113+
end
114+
115+
# Extracted `ElixirSense.Core.State.Env` contains all reachable aliases as a list of tuples
116+
# `{alias, aliased}`. If `aliased` is a prefix of `module_alias`, the function to be replaced
117+
# may use the corresponding `alias`.
118+
defp fetch_possible_aliases(source_file, one_based_line, module_alias) do
119+
metadata =
120+
source_file
121+
|> SourceFile.to_string()
122+
|> Parser.parse_string(true, true, one_based_line)
123+
124+
case metadata.lines_to_env[one_based_line] do
125+
%ElixirSense.Core.State.Env{aliases: aliases} ->
126+
possible_aliases =
127+
Enum.flat_map(aliases, fn {_alias, aliased} ->
128+
aliased = aliased |> Module.split() |> Enum.map(&String.to_atom/1)
129+
130+
if aliased == Enum.take(module_alias, length(aliased)) do
131+
[Enum.drop(module_alias, length(aliased) - 1)]
132+
else
133+
[]
134+
end
135+
end)
136+
137+
{:ok, [module_alias | possible_aliases]}
138+
139+
_ ->
140+
:error
141+
end
142+
end
143+
144+
defp text_edits_per_suggestion(line_text, line_ast, possible_aliases, name, suggestions) do
145+
suggestions
146+
|> Enum.reduce_while([], fn suggestion, acc ->
147+
case CodeMod.ReplaceRemoteFunction.text_edits(
148+
line_text,
149+
line_ast,
150+
possible_aliases,
151+
name,
152+
suggestion
153+
) do
154+
{:ok, []} -> {:cont, acc}
155+
{:ok, edits} -> {:cont, [{edits, suggestion} | acc]}
156+
:error -> {:halt, :error}
157+
end
158+
end)
159+
|> case do
160+
:error -> :error
161+
edits -> {:ok, edits}
162+
end
163+
end
164+
165+
defp update_line(%TextEdit{} = text_edit, line_number) do
166+
text_edit
167+
|> put_in([:range, :start, :line], line_number - 1)
168+
|> put_in([:range, :end, :line], line_number - 1)
169+
end
170+
171+
defp construct_title(module_alias, suggestion) do
172+
module_string = Enum.map_join(module_alias, ".", &Atom.to_string/1)
173+
174+
"Replace with #{module_string}.#{suggestion}"
175+
end
176+
end

apps/language_server/lib/language_server/experimental/provider/handlers/code_action.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do
2+
alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceRemoteFunction
23
alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore
34
alias ElixirLS.LanguageServer.Experimental.Provider.Env
45
alias ElixirLS.LanguageServer.Experimental.Protocol.Requests
56
alias ElixirLS.LanguageServer.Experimental.Protocol.Responses
6-
alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore
77

88
require Logger
99

10+
@code_actions [ReplaceRemoteFunction, ReplaceWithUnderscore]
11+
1012
def handle(%Requests.CodeAction{} = request, %Env{}) do
11-
code_actions = ReplaceWithUnderscore.apply(request)
13+
code_actions =
14+
Enum.flat_map(@code_actions, fn code_action_module -> code_action_module.apply(request) end)
15+
1216
reply = Responses.CodeAction.new(request.id, code_actions)
1317

1418
{:reply, reply}

apps/language_server/lib/language_server/experimental/source_file.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do
55
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextDocument.ContentChangeEvent.TextDocumentContentChangeEvent1,
66
as: ReplaceContentChangeEvent
77

8+
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit
89
alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions
910
alias ElixirLS.LanguageServer.Experimental.SourceFile.Document
1011
alias ElixirLS.LanguageServer.Experimental.SourceFile.Line
@@ -162,6 +163,14 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do
162163
end
163164
end
164165

166+
defp apply_change(%__MODULE__{} = source, %TextEdit{} = change) do
167+
with {:ok, ex_range} <- Conversions.to_elixir(change.range, source) do
168+
apply_change(source, ex_range, change.new_text)
169+
else
170+
_ -> {:error, {:invalid_range, change.range}}
171+
end
172+
end
173+
165174
defp apply_change(
166175
%__MODULE__{} = source,
167176
%{

0 commit comments

Comments
 (0)