Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add code actions for unknown modules and structs #891

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.AddAlias do
alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast
alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff
alias ElixirLS.LanguageServer.Experimental.SourceFile
alias ElixirSense.Core.Metadata
alias ElixirSense.Core.Parser
alias LSP.Types.TextEdit

@spec text_edits(SourceFile.t(), non_neg_integer(), [atom()]) ::
{:ok, [TextEdit.t()], non_neg_integer()} | :error
def text_edits(source_file, one_based_line, suggestion) do
maybe_blank_line_before(source_file, one_based_line)

with {:ok, {alias_line, alias_column}} <- find_place(source_file, one_based_line),
{:ok, line_text} <- SourceFile.fetch_text_at(source_file, alias_line),
{:ok, transformed} <-
apply_transforms(source_file, alias_line, alias_column, suggestion) do
{:ok, Diff.diff(line_text, transformed), alias_line}
end
end

defp find_place(source_file, one_based_line) do
metadata =
source_file
|> SourceFile.to_string()
|> Parser.parse_string(true, true, one_based_line)

case Metadata.get_position_to_insert_alias(metadata, one_based_line) do
nil -> :error
alias_position -> {:ok, alias_position}
end
end

defp apply_transforms(source_file, line, column, suggestion) do
case SourceFile.fetch_text_at(source_file, line) do
{:ok, line_text} ->
leading_indent = String.duplicate(" ", column - 1)

new_alias_text = Ast.to_string({:alias, [], [{:__aliases__, [], suggestion}]}) <> "\n"

maybe_blank_line_before = maybe_blank_line_before(source_file, line)
maybe_blank_line_after = maybe_blank_line_after(line_text)

{:ok,
"#{maybe_blank_line_before}#{leading_indent}#{new_alias_text}#{maybe_blank_line_after}#{line_text}"}

_ ->
:error
end
end

defp maybe_blank_line_before(source_file, line) do
if line >= 2 do
case SourceFile.fetch_text_at(source_file, line - 1) do
{:ok, previous_line_text} ->
cond do
blank?(previous_line_text) -> ""
contains_alias?(previous_line_text) -> ""
module_definition?(previous_line_text) -> ""
true -> "\n"
end

_ ->
"\n"
end
else
""
end
end

defp maybe_blank_line_after(line_text) do
cond do
blank?(line_text) -> ""
contains_alias?(line_text) -> ""
true -> "\n"
end
end

defp blank?(line_text) do
line_text |> String.trim() |> byte_size() == 0
end

defp contains_alias?(line_text) do
case Ast.from(line_text) do
{:ok, {:alias, _meta, _alias}} -> true
_ -> false
end
end

defp module_definition?(line_text) do
case Ast.from(line_text) do
{:ok, {:defmodule, _meta, _contents}} -> true
_ -> false
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceModule do
alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast
alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff
alias ElixirLS.LanguageServer.Experimental.CodeMod.Text
alias LSP.Types.TextEdit

@spec text_edits(String.t(), Ast.t(), [atom()], [atom()]) :: {:ok, [TextEdit.t()]} | :error
def text_edits(original_text, ast, module, suggestion) do
with {:ok, transformed} <- apply_transforms(original_text, ast, module, suggestion) do
{:ok, Diff.diff(original_text, transformed)}
end
end

defp apply_transforms(line_text, quoted_ast, module, suggestion) do
leading_indent = Text.leading_indent(line_text)

updated_ast =
Macro.postwalk(quoted_ast, fn
{:__aliases__, meta, ^module} -> {:__aliases__, meta, suggestion}
other -> other
end)

if updated_ast != quoted_ast do
updated_ast
|> Ast.to_string()
# We're dealing with a single error on a single line.
# If the line doesn't compile (like it has a do with no end), ElixirSense
# adds additional lines do documents with errors, so take the first line, as it's
# the properly transformed source
|> Text.fetch_line(0)
|> case do
{:ok, text} ->
{:ok, "#{leading_indent}#{text}"}

error ->
error
end
else
:error
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.AddAlias do
alias ElixirLS.LanguageServer.Experimental.CodeMod
alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast
alias ElixirLS.LanguageServer.Experimental.SourceFile
alias ElixirSense.Core.Metadata
alias ElixirSense.Core.Parser
alias ElixirSense.Core.State.Env
alias LSP.Requests.CodeAction
alias LSP.Types.CodeAction, as: CodeActionResult
alias LSP.Types.Diagnostic
alias LSP.Types.TextEdit
alias LSP.Types.Workspace

@undefined_module_re ~r/(.*) is undefined \(module (.*) is not available or is yet to be defined\)/s
@unknown_struct_re ~r/\(CompileError\) (.*).__struct__\/1 is undefined, cannot expand struct (.*). Make sure the struct name is correct./s

@spec apply(CodeAction.t()) :: [CodeActionResult.t()]
def apply(%CodeAction{} = code_action) do
source_file = code_action.source_file
diagnostics = get_in(code_action, [:context, :diagnostics]) || []

Enum.flat_map(diagnostics, fn %Diagnostic{} = diagnostic ->
one_based_line = extract_start_line(diagnostic)

with {:ok, module_string} <- parse_message(diagnostic.message),
true <- module_present?(source_file, one_based_line, module_string),
{:ok, suggestions} <- create_suggestions(module_string, source_file, one_based_line),
{:ok, replies} <- build_code_actions(source_file, one_based_line, suggestions) do
replies
else
_ -> []
end
end)
end

defp extract_start_line(%Diagnostic{} = diagnostic) do
diagnostic.range.start.line
end

defp parse_message(message) do
case Regex.scan(@undefined_module_re, message) do
[[_message, _function, module]] ->
{:ok, module}

_ ->
case Regex.scan(@unknown_struct_re, message) do
[[_message, module, module]] -> {:ok, module}
_ -> :error
end
end
end

defp module_present?(source_file, one_based_line, module_string) do
module = module_to_alias_list(module_string)

with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line),
{:ok, line_ast} <- Ast.from(line_text) do
line_ast
|> Macro.postwalk(false, fn
{:., _fun_meta, [{:__aliases__, _aliases_meta, ^module} | _fun]} = ast, _acc ->
{ast, true}

{:%, _struct_meta, [{:__aliases__, _aliases_meta, ^module} | _fields]} = ast, _acc ->
{ast, true}

other_ast, acc ->
{other_ast, acc}
end)
|> elem(1)
end
end

@max_suggestions 3
defp create_suggestions(module_string, source_file, one_based_line) do
with {:ok, current_namespace} <- current_module_namespace(source_file, one_based_line) do
suggestions =
ElixirSense.all_modules()
|> Enum.filter(&String.ends_with?(&1, "." <> module_string))
|> Enum.sort_by(&same_namespace?(&1, current_namespace))
|> Enum.take(@max_suggestions)
|> Enum.map(&module_to_alias_list/1)

{:ok, suggestions}
end
end

defp same_namespace?(suggested_module_string, current_namespace) do
suggested_module_namespace =
suggested_module_string
|> module_to_alias_list()
|> List.first()
|> Atom.to_string()

current_namespace == suggested_module_namespace
end

defp current_module_namespace(source_file, one_based_line) do
%Metadata{lines_to_env: lines_to_env} =
source_file
|> SourceFile.to_string()
|> Parser.parse_string(true, true, one_based_line)

case Map.get(lines_to_env, one_based_line) do
nil ->
:error

%Env{module: module} ->
namespace =
module
|> module_to_alias_list()
|> List.first()
|> Atom.to_string()

{:ok, namespace}
end
end

defp module_to_alias_list(module) when is_atom(module) do
case Atom.to_string(module) do
"Elixir." <> module_string -> module_to_alias_list(module_string)
module_string -> module_to_alias_list(module_string)
end
end

defp module_to_alias_list(module) when is_binary(module) do
module
|> String.split(".")
|> Enum.map(&String.to_atom/1)
end

defp build_code_actions(source_file, one_based_line, suggestions) do
with {:ok, edits_per_suggestion} <-
text_edits_per_suggestion(source_file, one_based_line, suggestions) do
case edits_per_suggestion do
[] ->
:error

[_ | _] ->
replies =
Enum.map(edits_per_suggestion, fn {text_edits, alias_line, suggestion} ->
text_edits = Enum.map(text_edits, &update_line(&1, alias_line))

CodeActionResult.new(
title: construct_title(suggestion),
kind: :quick_fix,
edit: Workspace.Edit.new(changes: %{source_file.uri => text_edits})
)
end)

{:ok, replies}
end
end
end

defp text_edits_per_suggestion(source_file, one_based_line, suggestions) do
suggestions
|> Enum.reduce_while([], fn suggestion, acc ->
case CodeMod.AddAlias.text_edits(source_file, one_based_line, suggestion) do
{:ok, [], _alias_line} -> {:cont, acc}
{:ok, edits, alias_line} -> {:cont, [{edits, alias_line, suggestion} | acc]}
:error -> {:halt, :error}
end
end)
|> case do
:error -> :error
edits -> {:ok, edits}
end
end

defp update_line(%TextEdit{} = text_edit, line_number) do
text_edit
|> put_in([:range, :start, :line], line_number - 1)
|> put_in([:range, :end, :line], line_number - 1)
end

defp construct_title(suggestion) do
module_string = Enum.map_join(suggestion, ".", &Atom.to_string/1)

"Add alias #{module_string}"
end
end
Loading