From f455dfa6b47ad4344cd663fcab7b890c3870d9e8 Mon Sep 17 00:00:00 2001 From: Samuel Heldak Date: Mon, 22 May 2023 13:15:30 +0200 Subject: [PATCH] add code actions for unknown modules and structs --- .../experimental/code_mod/add_alias.ex | 96 ++++++ .../experimental/code_mod/replace_module.ex | 42 +++ .../provider/code_action/add_alias.ex | 181 ++++++++++ .../provider/code_action/replace_module.ex | 170 ++++++++++ .../provider/handlers/code_action.ex | 4 +- .../provider/code_action/add_alias_test.exs | 319 ++++++++++++++++++ .../code_action/replace_module_test.exs | 222 ++++++++++++ 7 files changed, 1033 insertions(+), 1 deletion(-) create mode 100644 apps/language_server/lib/language_server/experimental/code_mod/add_alias.ex create mode 100644 apps/language_server/lib/language_server/experimental/code_mod/replace_module.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/code_action/add_alias.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/code_action/replace_module.ex create mode 100644 apps/language_server/test/experimental/provider/code_action/add_alias_test.exs create mode 100644 apps/language_server/test/experimental/provider/code_action/replace_module_test.exs diff --git a/apps/language_server/lib/language_server/experimental/code_mod/add_alias.ex b/apps/language_server/lib/language_server/experimental/code_mod/add_alias.ex new file mode 100644 index 000000000..e194e0a4f --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/code_mod/add_alias.ex @@ -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 diff --git a/apps/language_server/lib/language_server/experimental/code_mod/replace_module.ex b/apps/language_server/lib/language_server/experimental/code_mod/replace_module.ex new file mode 100644 index 000000000..ff493f399 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/code_mod/replace_module.ex @@ -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 diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/add_alias.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/add_alias.ex new file mode 100644 index 000000000..e43f00e0b --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/add_alias.ex @@ -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 diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_module.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_module.ex new file mode 100644 index 000000000..61a989546 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_module.ex @@ -0,0 +1,170 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceModule 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]) || [] + + diagnostics + |> Enum.flat_map(fn %Diagnostic{} = diagnostic -> + one_based_line = extract_start_line(diagnostic) + + with {:ok, module_string} <- parse_message(diagnostic.message), + {:ok, suggestions} <- create_suggestions(module_string, source_file, one_based_line), + module = module_to_alias_list(module_string), + {:ok, replies} <- + build_code_actions(source_file, one_based_line, module, 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 + + @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_string) do + module_string + |> String.split(".") + |> Enum.map(&String.to_atom/1) + end + + defp build_code_actions(source_file, one_based_line, module, suggestions) do + with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line), + {:ok, line_ast} <- Ast.from(line_text), + {:ok, edits_per_suggestion} <- + text_edits_per_suggestion(line_text, line_ast, module, suggestions) do + case edits_per_suggestion do + [] -> + :error + + [_ | _] -> + replies = + Enum.map(edits_per_suggestion, fn {text_edits, suggestion} -> + text_edits = Enum.map(text_edits, &update_line(&1, one_based_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(line_text, line_ast, module, suggestions) do + suggestions + |> Enum.reduce_while([], fn suggestion, acc -> + case CodeMod.ReplaceModule.text_edits( + line_text, + line_ast, + module, + suggestion + ) do + {:ok, []} -> {:cont, acc} + {:ok, edits} -> {:cont, [{edits, 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) + + "Replace with #{module_string}" + end +end diff --git a/apps/language_server/lib/language_server/experimental/provider/handlers/code_action.ex b/apps/language_server/lib/language_server/experimental/provider/handlers/code_action.ex index c1b8a7763..7b20bb9ec 100644 --- a/apps/language_server/lib/language_server/experimental/provider/handlers/code_action.ex +++ b/apps/language_server/lib/language_server/experimental/provider/handlers/code_action.ex @@ -1,4 +1,6 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do + alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.AddAlias + alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceModule alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceRemoteFunction alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore alias ElixirLS.LanguageServer.Experimental.Provider.Env @@ -7,7 +9,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do require Logger - @code_actions [ReplaceRemoteFunction, ReplaceWithUnderscore] + @code_actions [AddAlias, ReplaceModule, ReplaceRemoteFunction, ReplaceWithUnderscore] def handle(%Requests.CodeAction{} = request, %Env{}) do code_actions = diff --git a/apps/language_server/test/experimental/provider/code_action/add_alias_test.exs b/apps/language_server/test/experimental/provider/code_action/add_alias_test.exs new file mode 100644 index 000000000..6c1ee8ca0 --- /dev/null +++ b/apps/language_server/test/experimental/provider/code_action/add_alias_test.exs @@ -0,0 +1,319 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.AddAliasTest do + alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.AddAlias + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Document + alias ElixirLS.LanguageServer.Fixtures.LspProtocol + alias ElixirLS.LanguageServer.SourceFile.Path, as: SourceFilePath + alias LSP.Requests + alias LSP.Requests.CodeAction, as: CodeActionRequest + alias LSP.Types.CodeAction + alias LSP.Types.CodeAction, as: CodeActionReply + alias LSP.Types.Diagnostic + alias LSP.Types.Range + + import LspProtocol + import AddAlias + + use ExUnit.Case + use Patch + + setup do + {:ok, _} = start_supervised(SourceFile.Store) + :ok + end + + def module_diagnostic_message(module) do + """ + #{module} is undefined (module #{module} is not available or is yet to be defined) + """ + end + + def struct_diagnostic_message(module) do + """ + (CompileError) #{module}.__struct__/1 is undefined, cannot expand struct #{module}. Make sure the struct name is correct. If the struct name exists and is correct but it still cannot be found, you likely have cyclic module usage in your code + expanding struct: #{module}.__struct__/1 + """ + end + + defp code_action(file_body, file_path, line, opts \\ []) do + file_uri = SourceFilePath.to_uri(file_path) + SourceFile.Store.open(file_uri, file_body, 0) + + {:ok, range} = + build(Range, + start: [line: line, character: 0], + end: [line: line, character: 0] + ) + + message = + Keyword.get_lazy(opts, :diagnostic_message, fn -> + module_diagnostic_message("ExampleDefaultArgs") + end) + + diagnostic = Diagnostic.new(range: range, message: message) + {:ok, context} = build(CodeAction.Context, diagnostics: [diagnostic]) + + {:ok, action} = + build(CodeActionRequest, + text_document: [uri: file_uri], + range: range, + context: context + ) + + {:ok, action} = Requests.to_elixir(action) + + {file_uri, file_body, action} + end + + defp apply_selected_action({file_uri, file_body, code_action}, index) do + action = + code_action + |> apply() + |> Enum.at(index) + + assert %CodeActionReply{edit: %{changes: %{^file_uri => edits}}} = action + + {:ok, %SourceFile{document: document}} = + file_uri + |> SourceFile.new(file_body, 0) + |> SourceFile.apply_content_changes(1, edits) + + document + end + + test "produces no actions if the module is not found in the code" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArg.my_func("text") + end + end + ] + + assert {_, _, action} = code_action(actual_code, "/project/file.ex", 3) + assert [] = apply(action) + end + + test "produces no actions if the line is empty" do + {_, _, action} = code_action("", "/project/file.ex", 0) + assert [] = apply(action) + end + + test "produces no results if the diagnostic message doesn't fit the format" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] + + assert {_, _, action} = + code_action(actual_code, "/project/file.ex", 3, diagnostic_message: "This isn't cool") + + assert [] = apply(action) + end + + test "produces no results for buggy source code" do + {_, _, action} = + ~S[ + 1 + 2~/3 ; 4ab( + ] + |> code_action("/project/file.ex", 0) + + assert [] = apply(action) + end + + test "handles nil context" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] + + assert {_, _, action} = code_action(actual_code, "/project/file.ex", 3) + + action = put_in(action, [:context], nil) + + assert [] = apply(action) + end + + test "handles nil diagnostics" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] + + assert {_, _, action} = code_action(actual_code, "/project/file.ex", 3) + + action = put_in(action, [:context, :diagnostics], nil) + + assert [] = apply(action) + end + + test "handles empty diagnostics" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] + + assert {_, _, action} = code_action(actual_code, "/project/file.ex", 3) + + action = put_in(action, [:context, :diagnostics], []) + + assert [] = apply(action) + end + + test "add alias for an unknown struct" do + actual_code = ~S[ + defmodule MyModule do + def foo do + %ExampleStruct{} + end + end + ] + + diagnostic_message = struct_diagnostic_message("ExampleStruct") + + expected_doc = ~S[ + defmodule MyModule do + alias ElixirLS.LanguageServer.Fixtures.ExampleStruct + + def foo do + %ExampleStruct{} + end + end + ] |> Document.new() + + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 3, diagnostic_message: diagnostic_message) + |> apply_selected_action(0) + end + + test "add alias for an unknown function call" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] + + expected_doc = ~S[ + defmodule MyModule do + alias ElixirLS.LanguageServer.Fixtures.ExampleDefaultArgs + + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] |> Document.new() + + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 3) + |> apply_selected_action(0) + end + + test "add alias in a nested module" do + actual_code = ~S[ + defmodule MyModule do + defmodule Example do + def foo do + ExampleDefaultArgs.my_func("text") + end + end + end + ] + + expected_doc = ~S[ + defmodule MyModule do + defmodule Example do + alias ElixirLS.LanguageServer.Fixtures.ExampleDefaultArgs + + def foo do + ExampleDefaultArgs.my_func("text") + end + end + end + ] |> Document.new() + + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 4) + |> apply_selected_action(0) + end + + test "add alias when there are already other aliases" do + actual_code = ~S[ + defmodule MyModule do + alias ElixirLS.LanguageServer.Fixtures.ExampleDocs + + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] + + expected_doc = ~S[ + defmodule MyModule do + alias ElixirLS.LanguageServer.Fixtures.ExampleDefaultArgs + alias ElixirLS.LanguageServer.Fixtures.ExampleDocs + + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] |> Document.new() + + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 5) + |> apply_selected_action(0) + end + + test "add alias when there are already other directives" do + actual_code = ~S[ + defmodule MyModule do + @moduledoc """ + + """ + import ElixirLS.LanguageServer.Fixtures.ExampleDocs + + def foo do + ExampleDefaultArgs.my_func(@text) + end + end + ] + + expected_doc = ~S[ + defmodule MyModule do + @moduledoc """ + + """ + + alias ElixirLS.LanguageServer.Fixtures.ExampleDefaultArgs + + import ElixirLS.LanguageServer.Fixtures.ExampleDocs + + def foo do + ExampleDefaultArgs.my_func(@text) + end + end + ] |> Document.new() + + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 8) + |> apply_selected_action(0) + end +end diff --git a/apps/language_server/test/experimental/provider/code_action/replace_module_test.exs b/apps/language_server/test/experimental/provider/code_action/replace_module_test.exs new file mode 100644 index 000000000..e103f78d6 --- /dev/null +++ b/apps/language_server/test/experimental/provider/code_action/replace_module_test.exs @@ -0,0 +1,222 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceModuleTest do + alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceModule + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Document + alias ElixirLS.LanguageServer.Fixtures.LspProtocol + alias ElixirLS.LanguageServer.SourceFile.Path, as: SourceFilePath + alias LSP.Requests + alias LSP.Requests.CodeAction, as: CodeActionRequest + alias LSP.Types.CodeAction + alias LSP.Types.CodeAction, as: CodeActionReply + alias LSP.Types.Diagnostic + alias LSP.Types.Range + + import LspProtocol + import ReplaceModule + + use ExUnit.Case + use Patch + + setup do + {:ok, _} = start_supervised(SourceFile.Store) + :ok + end + + def module_diagnostic_message(module) do + """ + #{module} is undefined (module #{module} is not available or is yet to be defined) + """ + end + + def struct_diagnostic_message(module) do + """ + (CompileError) #{module}.__struct__/1 is undefined, cannot expand struct #{module}. Make sure the struct name is correct. If the struct name exists and is correct but it still cannot be found, you likely have cyclic momdule usage in your code + expanding struct: #{module}.__struct__/1 + """ + end + + defp code_action(file_body, file_path, line, opts \\ []) do + file_uri = SourceFilePath.to_uri(file_path) + SourceFile.Store.open(file_uri, file_body, 0) + + {:ok, range} = + build(Range, + start: [line: line, character: 0], + end: [line: line, character: 0] + ) + + message = + Keyword.get_lazy(opts, :diagnostic_message, fn -> + module_diagnostic_message("ExampleDefaultArgs") + end) + + diagnostic = Diagnostic.new(range: range, message: message) + {:ok, context} = build(CodeAction.Context, diagnostics: [diagnostic]) + + {:ok, action} = + build(CodeActionRequest, + text_document: [uri: file_uri], + range: range, + context: context + ) + + {:ok, action} = Requests.to_elixir(action) + + {file_uri, file_body, action} + end + + defp apply_selected_action({file_uri, file_body, code_action}, index) do + action = + code_action + |> apply() + |> Enum.at(index) + + assert %CodeActionReply{edit: %{changes: %{^file_uri => edits}}} = action + + {:ok, %SourceFile{document: document}} = + file_uri + |> SourceFile.new(file_body, 0) + |> SourceFile.apply_content_changes(1, edits) + + document + end + + test "produces no actions if the module is not found in the code" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArg.my_func("text") + end + end + ] + + assert {_, _, action} = code_action(actual_code, "/project/file.ex", 3) + assert [] = apply(action) + end + + test "produces no actions if the line is empty" do + {_, _, action} = code_action("", "/project/file.ex", 0) + assert [] = apply(action) + end + + test "produces no results if the diagnostic message doesn't fit the format" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] + + assert {_, _, action} = + code_action(actual_code, "/project/file.ex", 3, diagnostic_message: "This isn't cool") + + assert [] = apply(action) + end + + test "produces no results for buggy source code" do + {_, _, action} = + ~S[ + 1 + 2~/3 ; 4ab( + ] + |> code_action("/project/file.ex", 0) + + assert [] = apply(action) + end + + test "handles nil context" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] + + assert {_, _, action} = code_action(actual_code, "/project/file.ex", 3) + + action = put_in(action, [:context], nil) + + assert [] = apply(action) + end + + test "handles nil diagnostics" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] + + assert {_, _, action} = code_action(actual_code, "/project/file.ex", 3) + + action = put_in(action, [:context, :diagnostics], nil) + + assert [] = apply(action) + end + + test "handles empty diagnostics" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] + + assert {_, _, action} = code_action(actual_code, "/project/file.ex", 3) + + action = put_in(action, [:context, :diagnostics], []) + + assert [] = apply(action) + end + + test "replace module in the function call" do + actual_code = ~S[ + defmodule MyModule do + def foo do + ExampleDefaultArgs.my_func("text") + end + end + ] + + expected_doc = ~S[ + defmodule MyModule do + def foo do + ElixirLS.LanguageServer.Fixtures.ExampleDefaultArgs.my_func("text") + end + end + ] |> Document.new() + + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 3) + |> apply_selected_action(0) + end + + test "replace unknown struct" do + actual_code = ~S[ + defmodule MyModule do + def foo do + %ExampleStruct{} + end + end + ] + + diagnostic_message = struct_diagnostic_message("ExampleStruct") + + expected_doc = ~S[ + defmodule MyModule do + def foo do + %ElixirLS.LanguageServer.Fixtures.ExampleStruct{} + end + end + ] |> Document.new() + + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 3, diagnostic_message: diagnostic_message) + |> apply_selected_action(0) + end +end