Skip to content

Commit 1608bc3

Browse files
committed
add replace unknown local function code action
1 parent 46dfc9c commit 1608bc3

File tree

4 files changed

+527
-1
lines changed

4 files changed

+527
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceLocalFunction 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()) ::
8+
{:ok, [TextEdit.t()]} | :error
9+
def text_edits(original_text, ast, function, suggestion) do
10+
with {:ok, transformed} <-
11+
apply_transforms(original_text, ast, function, suggestion) do
12+
{:ok, Diff.diff(original_text, transformed)}
13+
end
14+
end
15+
16+
defp apply_transforms(line_text, quoted_ast, function, suggestion) do
17+
leading_indent = Text.leading_indent(line_text)
18+
19+
updated_ast =
20+
Macro.postwalk(quoted_ast, fn
21+
{^function, meta, context} ->
22+
{suggestion, meta, context}
23+
24+
other ->
25+
other
26+
end)
27+
28+
if updated_ast != quoted_ast do
29+
updated_ast
30+
|> Ast.to_string()
31+
# We're dealing with a single error on a single line.
32+
# If the line doesn't compile (like it has a do with no end), ElixirSense
33+
# adds additional lines do documents with errors, so take the first line, as it's
34+
# the properly transformed source
35+
|> Text.fetch_line(0)
36+
|> case do
37+
{:ok, text} ->
38+
{:ok, "#{leading_indent}#{text}"}
39+
40+
error ->
41+
error
42+
end
43+
else
44+
:error
45+
end
46+
end
47+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceLocalFunction do
2+
alias ElixirLS.LanguageServer.Experimental.CodeMod
3+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast
4+
alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction
5+
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction, as: CodeActionResult
6+
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Diagnostic
7+
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit
8+
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Workspace
9+
alias ElixirLS.LanguageServer.Experimental.SourceFile
10+
alias ElixirSense.Core.Metadata
11+
alias ElixirSense.Core.Parser
12+
13+
@function_re ~r/undefined function ([^\/]*)\/([0-9]*) \(expected (.*) to define such a function or for it to be imported, but none are available\)/
14+
15+
@spec apply(CodeAction.t()) :: [CodeActionResult.t()]
16+
def apply(%CodeAction{} = code_action) do
17+
source_file = code_action.source_file
18+
diagnostics = get_in(code_action, [:context, :diagnostics]) || []
19+
20+
diagnostics
21+
|> Enum.flat_map(fn %Diagnostic{} = diagnostic ->
22+
one_based_line = extract_start_line(diagnostic)
23+
24+
with {:ok, module, function, arity} <- parse_message(diagnostic.message),
25+
suggestions = create_suggestions(source_file, one_based_line, module, function, arity),
26+
{:ok, replies} <-
27+
build_code_actions(source_file, one_based_line, function, suggestions) do
28+
replies
29+
else
30+
_ -> []
31+
end
32+
end)
33+
end
34+
35+
defp extract_start_line(%Diagnostic{} = diagnostic) do
36+
diagnostic.range.start.line
37+
end
38+
39+
defp parse_message(message) do
40+
case Regex.scan(@function_re, message) do
41+
[[_, function, arity, module]] ->
42+
{:ok, Module.concat([module]), String.to_atom(function), String.to_integer(arity)}
43+
44+
_ ->
45+
:error
46+
end
47+
end
48+
49+
@generated_functions [:__info__, :module_info]
50+
@threshold 0.77
51+
@max_suggestions 5
52+
53+
defp create_suggestions(%SourceFile{} = source_file, one_based_line, module, function, arity) do
54+
source_string = SourceFile.to_string(source_file)
55+
56+
%Metadata{mods_funs_to_positions: module_functions} =
57+
Parser.parse_string(source_string, true, true, one_based_line)
58+
59+
module_functions
60+
|> Enum.flat_map(fn
61+
{{^module, suggestion, ^arity}, _info} ->
62+
distance =
63+
function
64+
|> Atom.to_string()
65+
|> String.jaro_distance(Atom.to_string(suggestion))
66+
67+
[{suggestion, distance}]
68+
69+
_ ->
70+
[]
71+
end)
72+
|> Enum.reject(&(elem(&1, 0) in @generated_functions))
73+
|> Enum.filter(&(elem(&1, 1) >= @threshold))
74+
|> Enum.sort(&(elem(&1, 1) >= elem(&2, 1)))
75+
|> Enum.take(@max_suggestions)
76+
|> Enum.sort(&(elem(&1, 0) <= elem(&2, 0)))
77+
|> Enum.map(&elem(&1, 0))
78+
end
79+
80+
defp build_code_actions(%SourceFile{} = source_file, one_based_line, function, suggestions) do
81+
with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line),
82+
{:ok, line_ast} <- Ast.from(line_text),
83+
{:ok, edits_per_suggestion} <-
84+
text_edits_per_suggestion(line_text, line_ast, function, suggestions) do
85+
case edits_per_suggestion do
86+
[] ->
87+
:error
88+
89+
[_ | _] ->
90+
replies =
91+
Enum.map(edits_per_suggestion, fn {text_edits, suggestion} ->
92+
text_edits = Enum.map(text_edits, &update_line(&1, one_based_line))
93+
94+
CodeActionResult.new(
95+
title: construct_title(suggestion),
96+
kind: :quick_fix,
97+
edit: Workspace.Edit.new(changes: %{source_file.uri => text_edits})
98+
)
99+
end)
100+
101+
{:ok, replies}
102+
end
103+
end
104+
end
105+
106+
defp text_edits_per_suggestion(line_text, line_ast, function, suggestions) do
107+
suggestions
108+
|> Enum.reduce_while([], fn suggestion, acc ->
109+
case CodeMod.ReplaceLocalFunction.text_edits(
110+
line_text,
111+
line_ast,
112+
function,
113+
suggestion
114+
) do
115+
{:ok, []} -> {:cont, acc}
116+
{:ok, edits} -> {:cont, [{edits, suggestion} | acc]}
117+
:error -> {:halt, :error}
118+
end
119+
end)
120+
|> case do
121+
:error -> :error
122+
edits -> {:ok, Enum.reverse(edits)}
123+
end
124+
end
125+
126+
defp update_line(%TextEdit{} = text_edit, line_number) do
127+
text_edit
128+
|> put_in([:range, :start, :line], line_number - 1)
129+
|> put_in([:range, :end, :line], line_number - 1)
130+
end
131+
132+
defp construct_title(suggestion) do
133+
"Replace with #{suggestion}"
134+
end
135+
end

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do
2+
alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceLocalFunction
23
alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceRemoteFunction
34
alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore
45
alias ElixirLS.LanguageServer.Experimental.Provider.Env
@@ -7,7 +8,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do
78

89
require Logger
910

10-
@code_actions [ReplaceRemoteFunction, ReplaceWithUnderscore]
11+
@code_actions [ReplaceLocalFunction, ReplaceRemoteFunction, ReplaceWithUnderscore]
1112

1213
def handle(%Requests.CodeAction{} = request, %Env{}) do
1314
code_actions =

0 commit comments

Comments
 (0)