Skip to content

Commit

Permalink
add code actions for unknown modules and structs
Browse files Browse the repository at this point in the history
  • Loading branch information
sheldak committed Jun 15, 2023
1 parent 7e11523 commit 85c9526
Show file tree
Hide file tree
Showing 7 changed files with 1,033 additions and 1 deletion.
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

0 comments on commit 85c9526

Please sign in to comment.