diff --git a/apps/language_server/lib/language_server/experimental/code_mod/replace_local_function.ex b/apps/language_server/lib/language_server/experimental/code_mod/replace_local_function.ex new file mode 100644 index 000000000..b65501812 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/code_mod/replace_local_function.ex @@ -0,0 +1,47 @@ +defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceLocalFunction 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, function, suggestion) do + with {:ok, transformed} <- + apply_transforms(original_text, ast, function, suggestion) do + {:ok, Diff.diff(original_text, transformed)} + end + end + + defp apply_transforms(line_text, quoted_ast, function, suggestion) do + leading_indent = Text.leading_indent(line_text) + + updated_ast = + Macro.postwalk(quoted_ast, fn + {^function, meta, context} -> + {suggestion, meta, context} + + 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/replace_local_function.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_local_function.ex new file mode 100644 index 000000000..30b5bd19d --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_local_function.ex @@ -0,0 +1,135 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceLocalFunction do + alias ElixirLS.LanguageServer.Experimental.CodeMod + alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast + alias LSP.Requests.CodeAction + alias LSP.Types.CodeAction, as: CodeActionResult + alias LSP.Types.Diagnostic + alias LSP.Types.TextEdit + alias LSP.Types.Workspace + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Parser + + @function_re ~r/undefined function ([^\/]*)\/([0-9]*) \(expected (.*) to define such a function or for it to be imported, but none are available\)/ + + @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, function, arity} <- parse_message(diagnostic.message), + suggestions = create_suggestions(source_file, one_based_line, module, function, arity), + {:ok, replies} <- + build_code_actions(source_file, one_based_line, function, 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(@function_re, message) do + [[_, function, arity, module]] -> + {:ok, Module.concat([module]), String.to_atom(function), String.to_integer(arity)} + + _ -> + :error + end + end + + @generated_functions [:__info__, :module_info] + @threshold 0.77 + @max_suggestions 5 + + defp create_suggestions(%SourceFile{} = source_file, one_based_line, module, function, arity) do + source_string = SourceFile.to_string(source_file) + + %Metadata{mods_funs_to_positions: module_functions} = + Parser.parse_string(source_string, true, true, one_based_line) + + module_functions + |> Enum.flat_map(fn + {{^module, suggestion, ^arity}, _info} -> + distance = + function + |> Atom.to_string() + |> String.jaro_distance(Atom.to_string(suggestion)) + + [{suggestion, distance}] + + _ -> + [] + end) + |> Enum.reject(&(elem(&1, 0) in @generated_functions)) + |> Enum.filter(&(elem(&1, 1) >= @threshold)) + |> Enum.sort(&(elem(&1, 1) >= elem(&2, 1))) + |> Enum.take(@max_suggestions) + |> Enum.sort(&(elem(&1, 0) <= elem(&2, 0))) + |> Enum.map(&elem(&1, 0)) + end + + defp build_code_actions(%SourceFile{} = source_file, one_based_line, function, 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, function, 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, function, suggestions) do + suggestions + |> Enum.reduce_while([], fn suggestion, acc -> + case CodeMod.ReplaceLocalFunction.text_edits( + line_text, + line_ast, + function, + suggestion + ) do + {:ok, []} -> {:cont, acc} + {:ok, edits} -> {:cont, [{edits, suggestion} | acc]} + :error -> {:halt, :error} + end + end) + |> case do + :error -> :error + edits -> {:ok, Enum.reverse(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 + "Replace with #{suggestion}" + 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..dd9f26c8d 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,5 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do + alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceLocalFunction alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceRemoteFunction alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore alias ElixirLS.LanguageServer.Experimental.Provider.Env @@ -7,7 +8,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do require Logger - @code_actions [ReplaceRemoteFunction, ReplaceWithUnderscore] + @code_actions [ReplaceLocalFunction, ReplaceRemoteFunction, ReplaceWithUnderscore] def handle(%Requests.CodeAction{} = request, %Env{}) do code_actions = diff --git a/apps/language_server/test/experimental/provider/code_action/replace_local_function_test.exs b/apps/language_server/test/experimental/provider/code_action/replace_local_function_test.exs new file mode 100644 index 000000000..93ebaf1cd --- /dev/null +++ b/apps/language_server/test/experimental/provider/code_action/replace_local_function_test.exs @@ -0,0 +1,343 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceLocalFunctionTest do + 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 + alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceLocalFunction + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Document + alias ElixirLS.LanguageServer.Fixtures.LspProtocol + alias ElixirLS.LanguageServer.SourceFile.Path, as: SourceFilePath + + import LspProtocol + import ReplaceLocalFunction + + use ExUnit.Case + use Patch + + setup do + {:ok, _} = start_supervised(SourceFile.Store) + :ok + end + + defp diagnostic_message(module, function_name, arity) do + "(CompileError) undefined function #{function_name}/#{arity} (expected #{inspect(module)} to define such a function or for it to be imported, but none are available)" + 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 -> + diagnostic_message(Example, :fo, 0) + 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 function is not found" do + message = diagnostic_message(Example, :bar, 0) + + {_, _, action} = ~S[ + defmodule Example do + def main do + fo() + end + def foo do + 42 + end + end + ] |> code_action("/project/file.ex", 3, diagnostic_message: message) + + assert [] = apply(action) + end + + test "produces no actions if the line is empty" do + {_, _, action} = code_action("", "/project/file.ex", 1) + assert [] = apply(action) + end + + test "produces no results if the diagnostic message doesn't fit the format" do + assert {_, _, action} = + code_action("", "/project/file.ex", 1, 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 + {_, _, action} = ~S[ + defmodule Example do + def main do + fo() + end + def foo do + 42 + end + end + ] |> code_action("/project/file.ex", 3) + + action = put_in(action, [:context], nil) + + assert [] = apply(action) + end + + test "handles nil diagnostics" do + {_, _, action} = ~S[ + defmodule Example do + def main do + fo() + end + def foo do + 42 + end + end + ] |> code_action("/project/file.ex", 3) + + action = put_in(action, [:context, :diagnostics], nil) + + assert [] = apply(action) + end + + test "handles empty diagnostics" do + {_, _, action} = ~S[ + defmodule Example do + def main do + fo() + end + def foo do + 42 + end + end + ] |> code_action("/project/file.ex", 3) + + action = put_in(action, [:context, :diagnostics], []) + + assert [] = apply(action) + end + + test "suggestions are sorted alphabetically" do + actual_code = ~S[ + defmodule Example do + def main do + fo() + end + def foo do + 42 + end + def f do + 43 + end + end + ] + + expected_doc = ~S[ + defmodule Example do + def main do + f() + end + def foo do + 42 + end + def f do + 43 + end + end + ] |> Document.new() + + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 3) + |> apply_selected_action(0) + + expected_doc = ~S[ + defmodule Example do + def main do + foo() + end + def foo do + 42 + end + def f do + 43 + end + end + ] |> Document.new() + + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 3) + |> apply_selected_action(1) + end + + test "suggested functions need to match the replaced function arity" do + actual_code = ~S[ + defmodule Example do + def main do + fo() + end + def foo do + 42 + end + def f(x) do + x + end + end + ] + + expected_doc = ~S[ + defmodule Example do + def main do + foo() + end + def foo do + 42 + end + def f(x) do + x + end + end + ] |> Document.new() + + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 3) + |> apply_selected_action(0) + end + + test "does not suggest too different functions" do + actual_code = ~S[ + defmodule Example do + def main do + fo() + end + def foo do + 42 + end + def ff do + 43 + end + end + ] + + expected_doc = ~S[ + defmodule Example do + def main do + foo() + end + def foo do + 42 + end + def ff do + 43 + end + end + ] |> Document.new() + + # Jaro distance between "fo" and "ff" is 0.6666666666666666 so less than the threshold + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 3) + |> apply_selected_action(0) + end + + test "works for a function assigned to a variable" do + actual_code = ~S[ + defmodule Example do + def main do + var = &fo/1 + end + def foo do + 42 + end + end + ] + + expected_doc = ~S[ + defmodule Example do + def main do + var = &foo/1 + end + def foo do + 42 + end + end + ] |> Document.new() + + assert expected_doc == + actual_code + |> code_action("/project/file.ex", 3) + |> apply_selected_action(0) + end + + test "does not suggest automatically generated functions" do + code = ~S[ + defmodule Example do + def main do + __inf__(:module) + module_inf() + end + end + ] + + message = diagnostic_message(Example, :__inf__, 1) + + assert [] = + code + |> code_action("/project/file.ex", 3, diagnostic_message: message) + |> then(fn {_, _, action} -> action end) + |> apply() + + message = diagnostic_message(Example, :module_inf, 0) + + assert [] = + code + |> code_action("/project/file.ex", 4, diagnostic_message: message) + |> then(fn {_, _, action} -> action end) + |> apply() + end +end