-
Notifications
You must be signed in to change notification settings - Fork 205
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add code actions for unknown modules and structs
- Loading branch information
Showing
7 changed files
with
1,033 additions
and
1 deletion.
There are no files selected for viewing
96 changes: 96 additions & 0 deletions
96
apps/language_server/lib/language_server/experimental/code_mod/add_alias.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
42 changes: 42 additions & 0 deletions
42
apps/language_server/lib/language_server/experimental/code_mod/replace_module.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
181 changes: 181 additions & 0 deletions
181
apps/language_server/lib/language_server/experimental/provider/code_action/add_alias.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.