Skip to content

Commit 85c9526

Browse files
committed
add code actions for unknown modules and structs
1 parent 7e11523 commit 85c9526

File tree

7 files changed

+1033
-1
lines changed

7 files changed

+1033
-1
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.AddAlias do
2+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast
3+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff
4+
alias ElixirLS.LanguageServer.Experimental.SourceFile
5+
alias ElixirSense.Core.Metadata
6+
alias ElixirSense.Core.Parser
7+
alias LSP.Types.TextEdit
8+
9+
@spec text_edits(SourceFile.t(), non_neg_integer(), [atom()]) ::
10+
{:ok, [TextEdit.t()], non_neg_integer()} | :error
11+
def text_edits(source_file, one_based_line, suggestion) do
12+
maybe_blank_line_before(source_file, one_based_line)
13+
14+
with {:ok, {alias_line, alias_column}} <- find_place(source_file, one_based_line),
15+
{:ok, line_text} <- SourceFile.fetch_text_at(source_file, alias_line),
16+
{:ok, transformed} <-
17+
apply_transforms(source_file, alias_line, alias_column, suggestion) do
18+
{:ok, Diff.diff(line_text, transformed), alias_line}
19+
end
20+
end
21+
22+
defp find_place(source_file, one_based_line) do
23+
metadata =
24+
source_file
25+
|> SourceFile.to_string()
26+
|> Parser.parse_string(true, true, one_based_line)
27+
28+
case Metadata.get_position_to_insert_alias(metadata, one_based_line) do
29+
nil -> :error
30+
alias_position -> {:ok, alias_position}
31+
end
32+
end
33+
34+
defp apply_transforms(source_file, line, column, suggestion) do
35+
case SourceFile.fetch_text_at(source_file, line) do
36+
{:ok, line_text} ->
37+
leading_indent = String.duplicate(" ", column - 1)
38+
39+
new_alias_text = Ast.to_string({:alias, [], [{:__aliases__, [], suggestion}]}) <> "\n"
40+
41+
maybe_blank_line_before = maybe_blank_line_before(source_file, line)
42+
maybe_blank_line_after = maybe_blank_line_after(line_text)
43+
44+
{:ok,
45+
"#{maybe_blank_line_before}#{leading_indent}#{new_alias_text}#{maybe_blank_line_after}#{line_text}"}
46+
47+
_ ->
48+
:error
49+
end
50+
end
51+
52+
defp maybe_blank_line_before(source_file, line) do
53+
if line >= 2 do
54+
case SourceFile.fetch_text_at(source_file, line - 1) do
55+
{:ok, previous_line_text} ->
56+
cond do
57+
blank?(previous_line_text) -> ""
58+
contains_alias?(previous_line_text) -> ""
59+
module_definition?(previous_line_text) -> ""
60+
true -> "\n"
61+
end
62+
63+
_ ->
64+
"\n"
65+
end
66+
else
67+
""
68+
end
69+
end
70+
71+
defp maybe_blank_line_after(line_text) do
72+
cond do
73+
blank?(line_text) -> ""
74+
contains_alias?(line_text) -> ""
75+
true -> "\n"
76+
end
77+
end
78+
79+
defp blank?(line_text) do
80+
line_text |> String.trim() |> byte_size() == 0
81+
end
82+
83+
defp contains_alias?(line_text) do
84+
case Ast.from(line_text) do
85+
{:ok, {:alias, _meta, _alias}} -> true
86+
_ -> false
87+
end
88+
end
89+
90+
defp module_definition?(line_text) do
91+
case Ast.from(line_text) do
92+
{:ok, {:defmodule, _meta, _contents}} -> true
93+
_ -> false
94+
end
95+
end
96+
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceModule do
2+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast
3+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff
4+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Text
5+
alias LSP.Types.TextEdit
6+
7+
@spec text_edits(String.t(), Ast.t(), [atom()], [atom()]) :: {:ok, [TextEdit.t()]} | :error
8+
def text_edits(original_text, ast, module, suggestion) do
9+
with {:ok, transformed} <- apply_transforms(original_text, ast, module, suggestion) do
10+
{:ok, Diff.diff(original_text, transformed)}
11+
end
12+
end
13+
14+
defp apply_transforms(line_text, quoted_ast, module, suggestion) do
15+
leading_indent = Text.leading_indent(line_text)
16+
17+
updated_ast =
18+
Macro.postwalk(quoted_ast, fn
19+
{:__aliases__, meta, ^module} -> {:__aliases__, meta, suggestion}
20+
other -> other
21+
end)
22+
23+
if updated_ast != quoted_ast do
24+
updated_ast
25+
|> Ast.to_string()
26+
# We're dealing with a single error on a single line.
27+
# If the line doesn't compile (like it has a do with no end), ElixirSense
28+
# adds additional lines do documents with errors, so take the first line, as it's
29+
# the properly transformed source
30+
|> Text.fetch_line(0)
31+
|> case do
32+
{:ok, text} ->
33+
{:ok, "#{leading_indent}#{text}"}
34+
35+
error ->
36+
error
37+
end
38+
else
39+
:error
40+
end
41+
end
42+
end
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.AddAlias do
2+
alias ElixirLS.LanguageServer.Experimental.CodeMod
3+
alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast
4+
alias ElixirLS.LanguageServer.Experimental.SourceFile
5+
alias ElixirSense.Core.Metadata
6+
alias ElixirSense.Core.Parser
7+
alias ElixirSense.Core.State.Env
8+
alias LSP.Requests.CodeAction
9+
alias LSP.Types.CodeAction, as: CodeActionResult
10+
alias LSP.Types.Diagnostic
11+
alias LSP.Types.TextEdit
12+
alias LSP.Types.Workspace
13+
14+
@undefined_module_re ~r/(.*) is undefined \(module (.*) is not available or is yet to be defined\)/s
15+
@unknown_struct_re ~r/\(CompileError\) (.*).__struct__\/1 is undefined, cannot expand struct (.*). Make sure the struct name is correct./s
16+
17+
@spec apply(CodeAction.t()) :: [CodeActionResult.t()]
18+
def apply(%CodeAction{} = code_action) do
19+
source_file = code_action.source_file
20+
diagnostics = get_in(code_action, [:context, :diagnostics]) || []
21+
22+
Enum.flat_map(diagnostics, fn %Diagnostic{} = diagnostic ->
23+
one_based_line = extract_start_line(diagnostic)
24+
25+
with {:ok, module_string} <- parse_message(diagnostic.message),
26+
true <- module_present?(source_file, one_based_line, module_string),
27+
{:ok, suggestions} <- create_suggestions(module_string, source_file, one_based_line),
28+
{:ok, replies} <- build_code_actions(source_file, one_based_line, suggestions) do
29+
replies
30+
else
31+
_ -> []
32+
end
33+
end)
34+
end
35+
36+
defp extract_start_line(%Diagnostic{} = diagnostic) do
37+
diagnostic.range.start.line
38+
end
39+
40+
defp parse_message(message) do
41+
case Regex.scan(@undefined_module_re, message) do
42+
[[_message, _function, module]] ->
43+
{:ok, module}
44+
45+
_ ->
46+
case Regex.scan(@unknown_struct_re, message) do
47+
[[_message, module, module]] -> {:ok, module}
48+
_ -> :error
49+
end
50+
end
51+
end
52+
53+
defp module_present?(source_file, one_based_line, module_string) do
54+
module = module_to_alias_list(module_string)
55+
56+
with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line),
57+
{:ok, line_ast} <- Ast.from(line_text) do
58+
line_ast
59+
|> Macro.postwalk(false, fn
60+
{:., _fun_meta, [{:__aliases__, _aliases_meta, ^module} | _fun]} = ast, _acc ->
61+
{ast, true}
62+
63+
{:%, _struct_meta, [{:__aliases__, _aliases_meta, ^module} | _fields]} = ast, _acc ->
64+
{ast, true}
65+
66+
other_ast, acc ->
67+
{other_ast, acc}
68+
end)
69+
|> elem(1)
70+
end
71+
end
72+
73+
@max_suggestions 3
74+
defp create_suggestions(module_string, source_file, one_based_line) do
75+
with {:ok, current_namespace} <- current_module_namespace(source_file, one_based_line) do
76+
suggestions =
77+
ElixirSense.all_modules()
78+
|> Enum.filter(&String.ends_with?(&1, "." <> module_string))
79+
|> Enum.sort_by(&same_namespace?(&1, current_namespace))
80+
|> Enum.take(@max_suggestions)
81+
|> Enum.map(&module_to_alias_list/1)
82+
83+
{:ok, suggestions}
84+
end
85+
end
86+
87+
defp same_namespace?(suggested_module_string, current_namespace) do
88+
suggested_module_namespace =
89+
suggested_module_string
90+
|> module_to_alias_list()
91+
|> List.first()
92+
|> Atom.to_string()
93+
94+
current_namespace == suggested_module_namespace
95+
end
96+
97+
defp current_module_namespace(source_file, one_based_line) do
98+
%Metadata{lines_to_env: lines_to_env} =
99+
source_file
100+
|> SourceFile.to_string()
101+
|> Parser.parse_string(true, true, one_based_line)
102+
103+
case Map.get(lines_to_env, one_based_line) do
104+
nil ->
105+
:error
106+
107+
%Env{module: module} ->
108+
namespace =
109+
module
110+
|> module_to_alias_list()
111+
|> List.first()
112+
|> Atom.to_string()
113+
114+
{:ok, namespace}
115+
end
116+
end
117+
118+
defp module_to_alias_list(module) when is_atom(module) do
119+
case Atom.to_string(module) do
120+
"Elixir." <> module_string -> module_to_alias_list(module_string)
121+
module_string -> module_to_alias_list(module_string)
122+
end
123+
end
124+
125+
defp module_to_alias_list(module) when is_binary(module) do
126+
module
127+
|> String.split(".")
128+
|> Enum.map(&String.to_atom/1)
129+
end
130+
131+
defp build_code_actions(source_file, one_based_line, suggestions) do
132+
with {:ok, edits_per_suggestion} <-
133+
text_edits_per_suggestion(source_file, one_based_line, suggestions) do
134+
case edits_per_suggestion do
135+
[] ->
136+
:error
137+
138+
[_ | _] ->
139+
replies =
140+
Enum.map(edits_per_suggestion, fn {text_edits, alias_line, suggestion} ->
141+
text_edits = Enum.map(text_edits, &update_line(&1, alias_line))
142+
143+
CodeActionResult.new(
144+
title: construct_title(suggestion),
145+
kind: :quick_fix,
146+
edit: Workspace.Edit.new(changes: %{source_file.uri => text_edits})
147+
)
148+
end)
149+
150+
{:ok, replies}
151+
end
152+
end
153+
end
154+
155+
defp text_edits_per_suggestion(source_file, one_based_line, suggestions) do
156+
suggestions
157+
|> Enum.reduce_while([], fn suggestion, acc ->
158+
case CodeMod.AddAlias.text_edits(source_file, one_based_line, suggestion) do
159+
{:ok, [], _alias_line} -> {:cont, acc}
160+
{:ok, edits, alias_line} -> {:cont, [{edits, alias_line, suggestion} | acc]}
161+
:error -> {:halt, :error}
162+
end
163+
end)
164+
|> case do
165+
:error -> :error
166+
edits -> {:ok, edits}
167+
end
168+
end
169+
170+
defp update_line(%TextEdit{} = text_edit, line_number) do
171+
text_edit
172+
|> put_in([:range, :start, :line], line_number - 1)
173+
|> put_in([:range, :end, :line], line_number - 1)
174+
end
175+
176+
defp construct_title(suggestion) do
177+
module_string = Enum.map_join(suggestion, ".", &Atom.to_string/1)
178+
179+
"Add alias #{module_string}"
180+
end
181+
end

0 commit comments

Comments
 (0)