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 local function #861

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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,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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 =
Expand Down
Loading
Loading