Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace unknown remote function #776

Merged
merged 7 commits into from
Mar 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceRemoteFunction 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(), atom()) ::
{:ok, [TextEdit.t()]} | :error
def text_edits(original_text, ast, possible_aliases, name, suggestion) do
with {:ok, transformed} <-
apply_transforms(original_text, ast, possible_aliases, name, suggestion) do
{:ok, Diff.diff(original_text, transformed)}
end
end

defp apply_transforms(line_text, quoted_ast, possible_aliases, name, suggestion) do
leading_indent = Text.leading_indent(line_text)

updated_ast =
Macro.postwalk(quoted_ast, fn
{:., function_meta, [{:__aliases__, module_meta, module_alias}, ^name]} ->
if module_alias in possible_aliases do
{:., function_meta, [{:__aliases__, module_meta, module_alias}, suggestion]}
else
{:., function_meta, [{:__aliases__, module_meta, module_alias}, name]}
end

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
@@ -1,6 +1,7 @@
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore 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(), String.t() | atom) :: {:ok, [TextEdit.t()]} | :error
Expand Down Expand Up @@ -28,7 +29,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do

defp apply_transform(line_text, quoted_ast, unused_variable_name) do
underscored_variable_name = :"_#{unused_variable_name}"
leading_indent = leading_indent(line_text)
leading_indent = Text.leading_indent(line_text)

Macro.postwalk(quoted_ast, fn
{^unused_variable_name, meta, context} ->
Expand All @@ -42,7 +43,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do
# 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
|> fetch_line(0)
|> Text.fetch_line(0)
|> case do
{:ok, text} ->
{:ok, "#{leading_indent}#{text}"}
Expand All @@ -51,24 +52,4 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do
error
end
end

@indent_regex ~r/^\s+/
defp leading_indent(line_text) do
case Regex.scan(@indent_regex, line_text) do
[indent] -> indent
_ -> ""
end
end

defp fetch_line(message, line_number) do
line =
message
|> String.split(["\r\n", "\r", "\n"])
|> Enum.at(line_number)

case line do
nil -> :error
other -> {:ok, other}
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Text do
@indent_regex ~r/^\s+/
def leading_indent(line_text) do
case Regex.scan(@indent_regex, line_text) do
[indent] -> indent
_ -> ""
end
end

def fetch_line(message, line_number) do
line =
message
|> String.split(["\r\n", "\r", "\n"])
|> Enum.at(line_number)

case line do
nil -> :error
other -> {:ok, other}
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceRemoteFunction do
@moduledoc """
Code actions that replace unknown remote function with ones suggested by the warning message
"""

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.Parser

@function_re ~r/(.*)\/(.*) is undefined or private. .*:\n(.*)/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)
suggestions = extract_suggestions(diagnostic.message)

with {:ok, module_alias, name} <- extract_function(diagnostic.message),
{:ok, replies} <-
build_code_actions(source_file, one_based_line, module_alias, name, suggestions) do
replies
else
_ -> []
end
end)
end

defp extract_function(message) do
case Regex.scan(@function_re, message) do
[[_, full_name, _, _]] ->
{module_alias, name} = separate_module_from_name(full_name)
{:ok, module_alias, name}

_ ->
:error
end
end

defp separate_module_from_name(full_name) do
{name, module_alias} =
full_name
|> String.split(".")
|> Enum.map(&String.to_atom/1)
|> List.pop_at(-1)

{module_alias, name}
end

@suggestion_re ~r/\* .*\/[\d]+/
defp extract_suggestions(message) do
case Regex.scan(@function_re, message) do
[[_, _, arity, suggestions_string]] ->
@suggestion_re
|> Regex.scan(suggestions_string)
|> Enum.flat_map(fn [suggestion] ->
case String.split(suggestion, [" ", "/"]) do
["*", name, ^arity] -> [String.to_atom(name)]
_ -> []
end
end)

_ ->
[]
end
end

defp extract_start_line(%Diagnostic{} = diagnostic) do
diagnostic.range.start.line
end

defp build_code_actions(
%SourceFile{} = source_file,
one_based_line,
module_alias,
name,
suggestions
) do
with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line),
{:ok, line_ast} <- Ast.from(line_text),
{:ok, possible_aliases} <-
fetch_possible_aliases(source_file, one_based_line, module_alias),
{:ok, edits_per_suggestion} <-
text_edits_per_suggestion(line_text, line_ast, possible_aliases, name, 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(module_alias, suggestion),
kind: :quick_fix,
edit: Workspace.Edit.new(changes: %{source_file.uri => text_edits})
)
end)

{:ok, replies}
end
end
end

# Extracted `ElixirSense.Core.State.Env` contains all reachable aliases as a list of tuples
# `{alias, aliased}`. If `aliased` is a prefix of `module_alias`, the function to be replaced
# may use the corresponding `alias`.
defp fetch_possible_aliases(source_file, one_based_line, module_alias) do
metadata =
source_file
|> SourceFile.to_string()
|> Parser.parse_string(true, true, one_based_line)

case metadata.lines_to_env[one_based_line] do
%ElixirSense.Core.State.Env{aliases: aliases} ->
possible_aliases =
Enum.flat_map(aliases, fn {_alias, aliased} ->
aliased = aliased |> Module.split() |> Enum.map(&String.to_atom/1)

if aliased == Enum.take(module_alias, length(aliased)) do
[Enum.drop(module_alias, length(aliased) - 1)]
else
[]
end
end)

{:ok, [module_alias | possible_aliases]}

_ ->
:error
end
end

defp text_edits_per_suggestion(line_text, line_ast, possible_aliases, name, suggestions) do
suggestions
|> Enum.reduce_while([], fn suggestion, acc ->
case CodeMod.ReplaceRemoteFunction.text_edits(
line_text,
line_ast,
possible_aliases,
name,
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(module_alias, suggestion) do
module_string = Enum.map_join(module_alias, ".", &Atom.to_string/1)

"Replace with #{module_string}.#{suggestion}"
end
end
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do
alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceRemoteFunction
alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore
alias ElixirLS.LanguageServer.Experimental.Provider.Env
alias ElixirLS.LanguageServer.Experimental.Protocol.Requests
alias ElixirLS.LanguageServer.Experimental.Protocol.Responses
alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore

require Logger

@code_actions [ReplaceRemoteFunction, ReplaceWithUnderscore]

def handle(%Requests.CodeAction{} = request, %Env{}) do
code_actions = ReplaceWithUnderscore.apply(request)
code_actions =
Enum.flat_map(@code_actions, fn code_action_module -> code_action_module.apply(request) end)

reply = Responses.CodeAction.new(request.id, code_actions)

{:reply, reply}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do
alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextDocument.ContentChangeEvent.TextDocumentContentChangeEvent1,
as: ReplaceContentChangeEvent

alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit
alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions
alias ElixirLS.LanguageServer.Experimental.SourceFile.Document
alias ElixirLS.LanguageServer.Experimental.SourceFile.Line
Expand Down Expand Up @@ -162,6 +163,14 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do
end
end

defp apply_change(%__MODULE__{} = source, %TextEdit{} = change) do
with {:ok, ex_range} <- Conversions.to_elixir(change.range, source) do
apply_change(source, ex_range, change.new_text)
else
_ -> {:error, {:invalid_range, change.range}}
end
end

defp apply_change(
%__MODULE__{} = source,
%{
Expand Down
Loading