From 1608bc38567f3fa01abeda3cac9c8cdbff8d41bc Mon Sep 17 00:00:00 2001
From: Samuel Heldak <samuel.heldak@gmail.com>
Date: Wed, 12 Apr 2023 15:44:36 +0200
Subject: [PATCH 1/2] add replace unknown local function code action

---
 .../code_mod/replace_local_function.ex        |  47 +++
 .../code_action/replace_local_function.ex     | 135 +++++++
 .../provider/handlers/code_action.ex          |   3 +-
 .../replace_local_function_test.exs           | 343 ++++++++++++++++++
 4 files changed, 527 insertions(+), 1 deletion(-)
 create mode 100644 apps/language_server/lib/language_server/experimental/code_mod/replace_local_function.ex
 create mode 100644 apps/language_server/lib/language_server/experimental/provider/code_action/replace_local_function.ex
 create mode 100644 apps/language_server/test/experimental/provider/code_action/replace_local_function_test.exs

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..ded1ef689
--- /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 ElixirLS.LanguageServer.Experimental.Protocol.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..0c8242da0
--- /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 ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction
+  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction, as: CodeActionResult
+  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Diagnostic
+  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit
+  alias ElixirLS.LanguageServer.Experimental.Protocol.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 3d899eccc..a30c13e8c 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..1bb33f993
--- /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 ElixirLS.LanguageServer.Experimental.Protocol.Requests
+  alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction, as: CodeActionRequest
+  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction
+  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction, as: CodeActionReply
+  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Diagnostic
+  alias ElixirLS.LanguageServer.Experimental.Protocol.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

From 2a356f8730402d101eeeaaeb59edff9f6f9a3e8d Mon Sep 17 00:00:00 2001
From: Samuel Heldak <samuel.heldak@gmail.com>
Date: Fri, 30 Jun 2023 11:04:37 +0200
Subject: [PATCH 2/2] update aliases

---
 .../experimental/code_mod/replace_local_function.ex  |  2 +-
 .../provider/code_action/replace_local_function.ex   | 10 +++++-----
 .../code_action/replace_local_function_test.exs      | 12 ++++++------
 3 files changed, 12 insertions(+), 12 deletions(-)

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
index ded1ef689..b65501812 100644
--- 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
@@ -2,7 +2,7 @@ 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 ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit
+  alias LSP.Types.TextEdit
 
   @spec text_edits(String.t(), Ast.t(), atom(), atom()) ::
           {:ok, [TextEdit.t()]} | :error
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
index 0c8242da0..30b5bd19d 100644
--- 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
@@ -1,11 +1,11 @@
 defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceLocalFunction do
   alias ElixirLS.LanguageServer.Experimental.CodeMod
   alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast
-  alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction
-  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction, as: CodeActionResult
-  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Diagnostic
-  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit
-  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Workspace
+  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
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
index 1bb33f993..93ebaf1cd 100644
--- 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
@@ -1,10 +1,10 @@
 defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceLocalFunctionTest do
-  alias ElixirLS.LanguageServer.Experimental.Protocol.Requests
-  alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction, as: CodeActionRequest
-  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction
-  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction, as: CodeActionReply
-  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Diagnostic
-  alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range
+  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