From 43b656d520582a40b1ac790957068fb243e1e67f Mon Sep 17 00:00:00 2001 From: Samuel Heldak Date: Thu, 25 Jan 2024 13:26:33 +0100 Subject: [PATCH] salvage code actions from experimental server --- .../lib/language_server/code_unit.ex | 180 +++++++++ .../lib/language_server/protocol.ex | 11 + .../lib/language_server/protocol/text_edit.ex | 3 + .../language_server/providers/code_action.ex | 12 + .../code_action/code_action_result.ex | 24 ++ .../providers/code_action/helpers.ex | 53 +++ .../code_action/replace_remote_function.ex | 194 ++++++++++ .../code_action/replace_with_underscore.ex | 114 ++++++ .../language_server/providers/code_mod/ast.ex | 29 ++ .../providers/code_mod/diff.ex | 102 ++++++ .../providers/code_mod/text.ex | 33 ++ .../lib/language_server/server.ex | 11 +- .../lib/language_server/source_file.ex | 14 +- .../replace_remote_function_test.exs | 346 ++++++++++++++++++ .../replace_with_underscore_test.exs | 265 ++++++++++++++ .../test/providers/code_mod/diff_test.exs | 233 ++++++++++++ .../test/support/code_mode_case.ex | 88 +++++ .../test/support/fixtures/remote_function.ex | 9 + 18 files changed, 1718 insertions(+), 3 deletions(-) create mode 100644 apps/language_server/lib/language_server/code_unit.ex create mode 100644 apps/language_server/lib/language_server/providers/code_action.ex create mode 100644 apps/language_server/lib/language_server/providers/code_action/code_action_result.ex create mode 100644 apps/language_server/lib/language_server/providers/code_action/helpers.ex create mode 100644 apps/language_server/lib/language_server/providers/code_action/replace_remote_function.ex create mode 100644 apps/language_server/lib/language_server/providers/code_action/replace_with_underscore.ex create mode 100644 apps/language_server/lib/language_server/providers/code_mod/ast.ex create mode 100644 apps/language_server/lib/language_server/providers/code_mod/diff.ex create mode 100644 apps/language_server/lib/language_server/providers/code_mod/text.ex create mode 100644 apps/language_server/test/providers/code_action/replace_remote_function_test.exs create mode 100644 apps/language_server/test/providers/code_action/replace_with_underscore_test.exs create mode 100644 apps/language_server/test/providers/code_mod/diff_test.exs create mode 100644 apps/language_server/test/support/code_mode_case.ex create mode 100644 apps/language_server/test/support/fixtures/remote_function.ex diff --git a/apps/language_server/lib/language_server/code_unit.ex b/apps/language_server/lib/language_server/code_unit.ex new file mode 100644 index 000000000..f7a2190f7 --- /dev/null +++ b/apps/language_server/lib/language_server/code_unit.ex @@ -0,0 +1,180 @@ +defmodule ElixirLS.LanguageServer.CodeUnit do + @moduledoc """ + Code unit and offset conversions + + The LSP protocol speaks in positions, which defines where something happens in a document. + Positions have a start and an end, which are defined as code unit _offsets_ from the beginning + of a line. this module helps to convert between utf8, which most of the world speaks + natively, and utf16, which has been forced upon us by microsoft. + + Converting between offsets and code units is 0(n), and allocations only happen if a + multi-byte character is detected, at which point, only that character is allocated. + This exploits the fact that most source code consists of ascii characters, with at best, + sporadic multi-byte characters in it. Thus, the vast majority of documents will not require + any allocations at all. + """ + @type utf8_code_unit :: non_neg_integer() + @type utf16_code_unit :: non_neg_integer() + @type utf8_offset :: non_neg_integer() + @type utf16_offset :: non_neg_integer() + + @type error :: {:error, :misaligned} | {:error, :out_of_bounds} + + # public + + @doc """ + Converts a utf8 character offset into a utf16 character offset. This implementation + clamps the maximum size of an offset so that any initial character position can be + passed in and the offset returned will reflect the end of the line. + """ + @spec utf16_offset(String.t(), utf8_offset()) :: utf16_offset() + def utf16_offset(binary, character_position) do + do_utf16_offset(binary, character_position, 0) + end + + @doc """ + Converts a utf16 character offset into a utf8 character offset. This implementation + clamps the maximum size of an offset so that any initial character position can be + passed in and the offset returned will reflect the end of the line. + """ + @spec utf8_offset(String.t(), utf16_offset()) :: utf8_offset() + def utf8_offset(binary, character_position) do + do_utf8_offset(binary, character_position, 0) + end + + @spec to_utf8(String.t(), utf16_code_unit()) :: {:ok, utf8_code_unit()} | error + def to_utf8(binary, utf16_unit) do + do_to_utf8(binary, utf16_unit, 0) + end + + @spec to_utf16(String.t(), utf8_code_unit()) :: {:ok, utf16_code_unit()} | error + def to_utf16(binary, utf16_unit) do + do_to_utf16(binary, utf16_unit, 0) + end + + def count(:utf16, binary) do + do_count_utf16(binary, 0) + end + + # Private + + # UTF-16 + + def do_count_utf16(<<>>, count) do + count + end + + def do_count_utf16(<>, count) when c < 128 do + do_count_utf16(rest, count + 1) + end + + def do_count_utf16(<>, count) do + increment = + <> + |> byte_size() + |> div(2) + + do_count_utf16(rest, count + increment) + end + + defp do_utf16_offset(_, 0, offset) do + offset + end + + defp do_utf16_offset(<<>>, _, offset) do + # this clause pegs the offset at the end of the string + # no matter the character index + offset + end + + defp do_utf16_offset(<>, remaining, offset) when c < 128 do + do_utf16_offset(rest, remaining - 1, offset + 1) + end + + defp do_utf16_offset(<>, remaining, offset) do + s = <> + increment = utf16_size(s) + do_utf16_offset(rest, remaining - 1, offset + increment) + end + + defp do_to_utf16(_, 0, utf16_unit) do + {:ok, utf16_unit} + end + + defp do_to_utf16(_, utf8_unit, _) when utf8_unit < 0 do + {:error, :misaligned} + end + + defp do_to_utf16(<<>>, _remaining, _utf16_unit) do + {:error, :out_of_bounds} + end + + defp do_to_utf16(<>, utf8_unit, utf16_unit) when c < 128 do + do_to_utf16(rest, utf8_unit - 1, utf16_unit + 1) + end + + defp do_to_utf16(<>, utf8_unit, utf16_unit) do + utf8_string = <> + increment = utf16_size(utf8_string) + decrement = byte_size(utf8_string) + + do_to_utf16(rest, utf8_unit - decrement, utf16_unit + increment) + end + + defp utf16_size(binary) when is_binary(binary) do + binary + |> :unicode.characters_to_binary(:utf8, :utf16) + |> byte_size() + |> div(2) + end + + # UTF-8 + + defp do_utf8_offset(_, 0, offset) do + offset + end + + defp do_utf8_offset(<<>>, _, offset) do + # this clause pegs the offset at the end of the string + # no matter the character index + offset + end + + defp do_utf8_offset(<>, remaining, offset) when c < 128 do + do_utf8_offset(rest, remaining - 1, offset + 1) + end + + defp do_utf8_offset(<>, remaining, offset) do + s = <> + increment = utf8_size(s) + decrement = utf16_size(s) + do_utf8_offset(rest, remaining - decrement, offset + increment) + end + + defp do_to_utf8(_, 0, utf8_unit) do + {:ok, utf8_unit} + end + + defp do_to_utf8(_, utf_16_units, _) when utf_16_units < 0 do + {:error, :misaligned} + end + + defp do_to_utf8(<<>>, _remaining, _utf8_unit) do + {:error, :out_of_bounds} + end + + defp do_to_utf8(<>, utf16_unit, utf8_unit) when c < 128 do + do_to_utf8(rest, utf16_unit - 1, utf8_unit + 1) + end + + defp do_to_utf8(<>, utf16_unit, utf8_unit) do + utf8_code_units = byte_size(<>) + utf16_code_units = utf16_size(<>) + + do_to_utf8(rest, utf16_unit - utf16_code_units, utf8_unit + utf8_code_units) + end + + defp utf8_size(binary) when is_binary(binary) do + byte_size(binary) + end +end diff --git a/apps/language_server/lib/language_server/protocol.ex b/apps/language_server/lib/language_server/protocol.ex index e30a8442f..98b675d99 100644 --- a/apps/language_server/lib/language_server/protocol.ex +++ b/apps/language_server/lib/language_server/protocol.ex @@ -209,6 +209,17 @@ defmodule ElixirLS.LanguageServer.Protocol do end end + defmacro code_action_req(id, uri, diagnostics) do + quote do + request(unquote(id), "textDocument/codeAction", %{ + "context" => %{"diagnostics" => unquote(diagnostics)}, + "textDocument" => %{ + "uri" => unquote(uri) + } + }) + end + end + # Other utilities defmacro range(start_line, start_character, end_line, end_character) do diff --git a/apps/language_server/lib/language_server/protocol/text_edit.ex b/apps/language_server/lib/language_server/protocol/text_edit.ex index 28a6c2dba..f2fcc8314 100644 --- a/apps/language_server/lib/language_server/protocol/text_edit.ex +++ b/apps/language_server/lib/language_server/protocol/text_edit.ex @@ -4,6 +4,9 @@ defmodule ElixirLS.LanguageServer.Protocol.TextEdit do For details see https://microsoft.github.io/language-server-protocol/specification#textEdit """ + + @type t :: %__MODULE__{} + @derive JasonV.Encoder defstruct [:range, :newText] end diff --git a/apps/language_server/lib/language_server/providers/code_action.ex b/apps/language_server/lib/language_server/providers/code_action.ex new file mode 100644 index 000000000..753088a07 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/code_action.ex @@ -0,0 +1,12 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeAction do + alias ElixirLS.LanguageServer.Providers.CodeAction.ReplaceRemoteFunction + alias ElixirLS.LanguageServer.Providers.CodeAction.ReplaceWithUnderscore + + @code_actions [ReplaceRemoteFunction, ReplaceWithUnderscore] + + def code_actions(source_file, uri, diagnostic) do + code_actions = Enum.flat_map(@code_actions, & &1.apply(source_file, uri, diagnostic)) + + {:ok, code_actions} + end +end diff --git a/apps/language_server/lib/language_server/providers/code_action/code_action_result.ex b/apps/language_server/lib/language_server/providers/code_action/code_action_result.ex new file mode 100644 index 000000000..dd9482df5 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/code_action/code_action_result.ex @@ -0,0 +1,24 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeAction.CodeActionResult do + alias ElixirLS.LanguageServer.Protocol.TextEdit + + @type t :: %{ + title: String.t(), + kind: String.t(), + edit: %{ + changes: %{String.t() => TextEdit.t()} + } + } + + @spec new(String.t(), String.t(), [TextEdit.t()], String.t()) :: t() + def new(title, kind, text_edits, uri) do + %{ + :title => title, + :kind => kind, + :edit => %{ + :changes => %{ + uri => text_edits + } + } + } + end +end diff --git a/apps/language_server/lib/language_server/providers/code_action/helpers.ex b/apps/language_server/lib/language_server/providers/code_action/helpers.ex new file mode 100644 index 000000000..cc4a50358 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/code_action/helpers.ex @@ -0,0 +1,53 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeAction.Helpers do + alias ElixirLS.LanguageServer.Protocol.TextEdit + alias ElixirLS.LanguageServer.Providers.CodeMod.Ast + alias ElixirLS.LanguageServer.Providers.CodeMod.Text + + @spec update_line(TextEdit.t(), non_neg_integer()) :: TextEdit.t() + def update_line( + %TextEdit{range: %{"start" => start_line, "end" => end_line}} = text_edit, + line_number + ) do + %TextEdit{ + text_edit + | range: %{ + "start" => %{start_line | "line" => line_number}, + "end" => %{end_line | "line" => line_number} + } + } + end + + @spec to_one_line_string(Ast.t()) :: {:ok, String.t()} | :error + def to_one_line_string(updated_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 to documents with errors. Also, in case of a one-line do, + # ElixirSense creates do with end from the AST. + |> maybe_recover_one_line_do(updated_ast) + |> Text.fetch_line(0) + end + + @do_regex ~r/\s*do\s*/ + defp maybe_recover_one_line_do(updated_text, {_name, context, _children} = _updated_ast) do + wrong_do_end_conditions = [ + not Keyword.has_key?(context, :do), + not Keyword.has_key?(context, :end), + Regex.match?(@do_regex, updated_text), + String.ends_with?(updated_text, "\nend") + ] + + if Enum.all?(wrong_do_end_conditions) do + updated_text + |> String.replace(@do_regex, ", do: ") + |> String.trim_trailing("\nend") + else + updated_text + end + end + + defp maybe_recover_one_line_do(updated_text, _updated_ast) do + updated_text + end +end diff --git a/apps/language_server/lib/language_server/providers/code_action/replace_remote_function.ex b/apps/language_server/lib/language_server/providers/code_action/replace_remote_function.ex new file mode 100644 index 000000000..52031c4b2 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/code_action/replace_remote_function.ex @@ -0,0 +1,194 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeAction.ReplaceRemoteFunction do + @moduledoc """ + Code actions that replace unknown remote function with functions from the same module that have + similar names + """ + + use ElixirLS.LanguageServer.Protocol + + alias ElixirLS.LanguageServer.Providers.CodeAction.CodeActionResult + alias ElixirLS.LanguageServer.Providers.CodeMod.Ast + alias ElixirLS.LanguageServer.Providers.CodeMod.Diff + alias ElixirLS.LanguageServer.Providers.CodeMod.Text + alias ElixirLS.LanguageServer.SourceFile + alias ElixirSense.Core.Parser + + import ElixirLS.LanguageServer.Providers.CodeAction.Helpers + + @spec apply(SourceFile.t(), String.t(), [map()]) :: [CodeActionResult.t()] + def apply(%SourceFile{} = source_file, uri, diagnostics) do + Enum.flat_map(diagnostics, fn diagnostic -> + with {:ok, module, function, arity, line_number} <- extract_function_and_line(diagnostic), + {:ok, suggestions} <- prepare_suggestions(module, function, arity) do + to_code_actions(source_file, line_number, module, function, suggestions, uri) + else + _ -> [] + end + end) + end + + defp extract_function_and_line(diagnostic) do + with {:ok, module, function, arity} <- extract_function(diagnostic["message"]) do + {:ok, module, function, arity, diagnostic["range"]["start"]["line"]} + end + end + + @function_re ~r/(\S+)\/(\d+) is undefined or private. Did you mean:.*/ + defp extract_function(message) do + with [[_, module_and_function, arity]] <- Regex.scan(@function_re, message), + {:ok, module, function_name} <- separate_module_from_function(module_and_function) do + {:ok, module, function_name, String.to_integer(arity)} + end + end + + defp separate_module_from_function(module_and_function) do + module_and_function + |> String.split(".") + |> List.pop_at(-1) + |> case do + {function_name, [_ | _] = module_alias} -> + {:ok, alias_to_module(module_alias), function_name} + + _ -> + :error + end + end + + defp alias_to_module([":" <> erlang_alias]) do + String.to_atom(erlang_alias) + end + + defp alias_to_module(module_alias) do + Module.concat(module_alias) + end + + @function_threshold 0.77 + @max_suggestions 5 + defp prepare_suggestions(module, function, arity) do + suggestions = + for {module_function, ^arity} <- module_functions(module), + distance = module_function |> Atom.to_string() |> String.jaro_distance(function), + distance >= @function_threshold do + {distance, module_function} + end + |> Enum.sort(:desc) + |> Enum.take(@max_suggestions) + |> Enum.map(fn {_distance, module_function} -> module_function end) + + {:ok, suggestions} + end + + defp module_functions(module) do + if function_exported?(module, :__info__, 1) do + module.__info__(:functions) + else + module.module_info(:functions) + end + end + + defp to_code_actions(%SourceFile{} = source_file, line_number, module, name, suggestions, uri) do + suggestions + |> Enum.reduce([], fn suggestion, acc -> + case apply_transform(source_file, line_number, module, name, suggestion) do + {:ok, [_ | _] = text_edits} -> + text_edits = Enum.map(text_edits, &update_line(&1, line_number)) + + code_action = + CodeActionResult.new("Rename to #{suggestion}", "quickfix", text_edits, uri) + + [code_action | acc] + + _ -> + acc + end + end) + |> Enum.reverse() + end + + defp apply_transform(source_file, line_number, module, name, suggestion) do + with {:ok, text} <- fetch_line(source_file, line_number), + {:ok, ast} <- Ast.from(text) do + function_atom = String.to_atom(name) + + leading_indent = Text.leading_indent(text) + trailing_comment = Text.trailing_comment(text) + + ast + |> Macro.postwalk(fn + {:., function_meta, [{:__aliases__, module_meta, module_alias}, ^function_atom]} -> + case expand_alias(source_file, module_alias, line_number) do + {:ok, ^module} -> + {:., function_meta, [{:__aliases__, module_meta, module_alias}, suggestion]} + + _ -> + {:., function_meta, [{:__aliases__, module_meta, module_alias}, function_atom]} + end + + # erlang call + {:., function_meta, [^module, ^function_atom]} -> + {:., function_meta, [module, suggestion]} + + other -> + other + end) + |> to_one_line_string() + |> case do + {:ok, updated_text} -> + text_edits = Diff.diff(text, "#{leading_indent}#{updated_text}#{trailing_comment}") + + {:ok, text_edits} + + :error -> + :error + end + end + end + + defp fetch_line(%SourceFile{} = source_file, line_number) do + lines = SourceFile.lines(source_file) + + if length(lines) > line_number do + {:ok, Enum.at(lines, line_number)} + else + :error + end + end + + @spec expand_alias(SourceFile.t(), [atom()], non_neg_integer()) :: {:ok, atom()} | :error + defp expand_alias(source_file, module_alias, line_number) do + with {:ok, aliases} <- aliases_at(source_file, line_number) do + aliases + |> Enum.map(fn {module, aliased} -> + module = module |> module_to_alias() |> List.first() + aliased = module_to_alias(aliased) + + {module, aliased} + end) + |> Enum.find(fn {module, _aliased} -> List.starts_with?(module_alias, [module]) end) + |> case do + {_module, aliased} -> + module_alias = aliased ++ Enum.drop(module_alias, 1) + + {:ok, Module.concat(module_alias)} + + nil -> + {:ok, Module.concat(module_alias)} + end + end + end + + defp aliases_at(source_file, line_number) do + one_based_line = line_number + 1 + + metadata = Parser.parse_string(source_file.text, true, true, {one_based_line, 1}) + + case metadata.lines_to_env[one_based_line] do + %ElixirSense.Core.State.Env{aliases: aliases} -> {:ok, aliases} + _ -> :error + end + end + + defp module_to_alias(module) do + module |> Module.split() |> Enum.map(&String.to_atom/1) + end +end diff --git a/apps/language_server/lib/language_server/providers/code_action/replace_with_underscore.ex b/apps/language_server/lib/language_server/providers/code_action/replace_with_underscore.ex new file mode 100644 index 000000000..311b92239 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/code_action/replace_with_underscore.ex @@ -0,0 +1,114 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeAction.ReplaceWithUnderscore do + @moduledoc """ + A code action that prefixes unused variables with an underscore + """ + + use ElixirLS.LanguageServer.Protocol + + alias ElixirLS.LanguageServer.Protocol.TextEdit + alias ElixirLS.LanguageServer.Providers.CodeAction.CodeActionResult + alias ElixirLS.LanguageServer.Providers.CodeMod.Ast + alias ElixirLS.LanguageServer.Providers.CodeMod.Diff + alias ElixirLS.LanguageServer.Providers.CodeMod.Text + alias ElixirLS.LanguageServer.SourceFile + + import ElixirLS.LanguageServer.Providers.CodeAction.Helpers + + @spec apply(SourceFile.t(), String.t(), [map()]) :: [CodeActionResult.t()] + def apply(%SourceFile{} = source_file, uri, diagnostics) do + Enum.flat_map(diagnostics, fn diagnostic -> + with {:ok, variable_name, line_number} <- extract_variable_and_line(diagnostic), + {:ok, reply} <- build_code_action(source_file, uri, line_number, variable_name) do + [reply] + else + _ -> + [] + end + end) + end + + defp extract_variable_and_line(diagnostic) do + with {:ok, variable_name} <- extract_variable_name(diagnostic["message"]) do + {:ok, variable_name, diagnostic["range"]["start"]["line"]} + end + end + + @variable_re ~r/variable "([^"]+)" is unused/ + defp extract_variable_name(message) do + case Regex.scan(@variable_re, message) do + [[_, variable_name]] -> + {:ok, String.to_atom(variable_name)} + + _ -> + :error + end + end + + defp build_code_action(%SourceFile{} = source_file, uri, line_number, variable_name) do + with {:ok, line_text} <- fetch_line(source_file, line_number), + {:ok, line_ast} <- Ast.from(line_text), + {:ok, text_edits} <- text_edits(line_text, line_ast, variable_name) do + case text_edits do + [] -> + :error + + [_ | _] -> + text_edits = Enum.map(text_edits, &update_line(&1, line_number)) + + reply = + CodeActionResult.new( + "Rename to _#{variable_name}", + "quickfix", + text_edits, + uri + ) + + {:ok, reply} + end + end + end + + defp fetch_line(%SourceFile{} = source_file, line_number) do + lines = SourceFile.lines(source_file) + + if length(lines) > line_number do + {:ok, Enum.at(lines, line_number)} + else + :error + end + end + + @spec text_edits(String.t(), Ast.t(), atom()) :: {:ok, [TextEdit.t()]} | :error + defp text_edits(original_text, ast, variable_name) do + with {:ok, transformed} <- apply_transform(original_text, ast, variable_name) do + {:ok, to_text_edits(original_text, transformed)} + end + end + + defp apply_transform(line_text, quoted_ast, unused_variable_name) do + underscored_variable_name = :"_#{unused_variable_name}" + leading_indent = Text.leading_indent(line_text) + + Macro.postwalk(quoted_ast, fn + {^unused_variable_name, meta, nil} -> + {underscored_variable_name, meta, nil} + + other -> + other + end) + |> to_one_line_string() + |> case do + {:ok, text} -> + {:ok, "#{leading_indent}#{text}"} + + :error -> + :error + end + end + + defp to_text_edits(original_text, fixed_text) do + original_text + |> Diff.diff(fixed_text) + |> Enum.filter(&(&1.newText == "_")) + end +end diff --git a/apps/language_server/lib/language_server/providers/code_mod/ast.ex b/apps/language_server/lib/language_server/providers/code_mod/ast.ex new file mode 100644 index 000000000..7749330a4 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/code_mod/ast.ex @@ -0,0 +1,29 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeMod.Ast do + alias ElixirLS.LanguageServer.SourceFile + + @type source :: SourceFile.t() | String.t() + @type t :: + atom() + | binary() + | [any()] + | number() + | {any(), any()} + | {atom() | {any(), [any()], atom() | [any()]}, Keyword.t(), atom() | [any()]} + + @spec from(source() | String.t()) :: {:ok, t()} | :error + def from(%SourceFile{text: text}) do + from(text) + end + + def from(text) when is_binary(text) do + case ElixirSense.string_to_quoted(text, {1, 1}) do + {:ok, ast} -> {:ok, ast} + _ -> :error + end + end + + @spec to_string(t()) :: String.t() + def to_string(ast) do + Macro.to_string(ast) + end +end diff --git a/apps/language_server/lib/language_server/providers/code_mod/diff.ex b/apps/language_server/lib/language_server/providers/code_mod/diff.ex new file mode 100644 index 000000000..24599de48 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/code_mod/diff.ex @@ -0,0 +1,102 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeMod.Diff do + alias ElixirLS.LanguageServer.CodeUnit + alias ElixirLS.LanguageServer.Protocol.TextEdit + + import ElixirLS.LanguageServer.Protocol + + @spec diff(String.t(), String.t()) :: [TextEdit.t()] + def diff(source, dest) do + source + |> String.myers_difference(dest) + |> to_text_edits() + end + + defp to_text_edits(difference) do + {_, {current_line, prev_lines}} = + Enum.reduce(difference, {{0, 0}, {[], []}}, fn + {diff_type, diff_string}, {position, edits} -> + apply_diff(diff_type, position, diff_string, edits) + end) + + [current_line | prev_lines] + |> Enum.flat_map(fn line_edits -> + line_edits + |> Enum.reduce([], &collapse/2) + |> Enum.reverse() + end) + end + + # This collapses a delete and an insert that are adjacent to one another + # into a single insert, changing the delete to insert the text from the + # insert rather than "" + defp collapse( + %TextEdit{ + newText: "", + range: %{ + "end" => %{"character" => same_character, "line" => same_line} + } + } = delete_edit, + [ + %TextEdit{ + newText: insert_text, + range: + %{ + "start" => %{"character" => same_character, "line" => same_line} + } = _insert_edit + } + | rest + ] + ) + when byte_size(insert_text) > 0 do + collapsed_edit = %TextEdit{delete_edit | newText: insert_text} + [collapsed_edit | rest] + end + + defp collapse(%TextEdit{} = edit, edits) do + [edit | edits] + end + + defp apply_diff(:eq, position, doc_string, edits) do + advance(doc_string, position, edits) + end + + defp apply_diff(:del, {line, code_unit} = position, change, edits) do + {after_pos, {current_line, prev_lines}} = advance(change, position, edits) + {edit_end_line, edit_end_unit} = after_pos + current_line = [edit("", line, code_unit, edit_end_line, edit_end_unit) | current_line] + {after_pos, {current_line, prev_lines}} + end + + defp apply_diff(:ins, {line, code_unit} = position, change, {current_line, prev_lines}) do + current_line = [edit(change, line, code_unit, line, code_unit) | current_line] + # When inserting, the insert itself does not exist in source, so there is no need to advance + {position, {current_line, prev_lines}} + end + + defp advance(<<>>, position, edits) do + {position, edits} + end + + for ending <- ["\r\n", "\r", "\n"] do + defp advance(<>, {line, _unit}, {current_line, prev_lines}) do + edits = {[], [current_line | prev_lines]} + advance(rest, {line + 1, 0}, edits) + end + end + + defp advance(<>, {line, unit}, edits) when c < 128 do + advance(rest, {line, unit + 1}, edits) + end + + defp advance(<>, {line, unit}, edits) do + increment = CodeUnit.count(:utf16, <>) + advance(rest, {line, unit + increment}, edits) + end + + defp edit(text, start_line, start_unit, end_line, end_unit) do + %TextEdit{ + newText: text, + range: range(start_line, start_unit, end_line, end_unit) + } + end +end diff --git a/apps/language_server/lib/language_server/providers/code_mod/text.ex b/apps/language_server/lib/language_server/providers/code_mod/text.ex new file mode 100644 index 000000000..ab3fed99b --- /dev/null +++ b/apps/language_server/lib/language_server/providers/code_mod/text.ex @@ -0,0 +1,33 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeMod.Text do + @indent_regex ~r/^\s+/ + @comment_regex ~r/\s*#.*/ + + @spec leading_indent(String.t()) :: String.t() + def leading_indent(line_text) do + case Regex.scan(@indent_regex, line_text) do + [indent] -> indent + _ -> "" + end + end + + @spec trailing_comment(String.t()) :: String.t() + def trailing_comment(line_text) do + case Regex.scan(@comment_regex, line_text) do + [comment] -> comment + _ -> "" + end + end + + @spec fetch_line(String.t(), non_neg_integer()) :: {:ok, String.t()} | :error + 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 diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index b36593df8..74c1a44bd 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -43,7 +43,8 @@ defmodule ElixirLS.LanguageServer.Server do CodeLens, ExecuteCommand, FoldingRange, - SelectionRanges + SelectionRanges, + CodeAction } alias ElixirLS.Utils.Launch @@ -1253,6 +1254,12 @@ defmodule ElixirLS.LanguageServer.Server do {:async, fun, state} end + defp handle_request(code_action_req(_id, uri, diagnostics), state = %__MODULE__{}) do + source_file = get_source_file(state, uri) + + {:async, fn -> CodeAction.code_actions(source_file, uri, diagnostics) end, state} + end + defp handle_request(%{"method" => "$/" <> _}, state = %__MODULE__{}) do # "$/" requests that the server doesn't support must return method_not_found {:error, :method_not_found, nil, false, state} @@ -1309,7 +1316,7 @@ defmodule ElixirLS.LanguageServer.Server do "workspaceFolders" => %{"supported" => false, "changeNotifications" => false} }, "foldingRangeProvider" => true, - "codeActionProvider" => false + "codeActionProvider" => true } end diff --git a/apps/language_server/lib/language_server/source_file.ex b/apps/language_server/lib/language_server/source_file.ex index 59dac7fd6..46e088908 100644 --- a/apps/language_server/lib/language_server/source_file.ex +++ b/apps/language_server/lib/language_server/source_file.ex @@ -1,8 +1,12 @@ defmodule ElixirLS.LanguageServer.SourceFile do + alias ElixirLS.LanguageServer.Protocol.TextEdit + import ElixirLS.LanguageServer.Protocol require ElixirSense.Core.Introspection, as: Introspection require Logger + @type t :: %__MODULE__{} + defstruct [:text, :version, :language_id, dirty?: false] @endings ["\r\n", "\r", "\n"] @@ -69,7 +73,7 @@ defmodule ElixirLS.LanguageServer.SourceFile do def apply_content_changes(%__MODULE__{} = source_file, [edit | rest]) do source_file = - case edit do + case maybe_convert_text_edit(edit) do %{"range" => edited_range, "text" => new_text} when not is_nil(edited_range) -> update_in(source_file.text, fn text -> apply_edit(text, edited_range, new_text) @@ -82,6 +86,14 @@ defmodule ElixirLS.LanguageServer.SourceFile do apply_content_changes(source_file, rest) end + defp maybe_convert_text_edit(%TextEdit{range: range, newText: new_text}) do + %{"range" => range, "text" => new_text} + end + + defp maybe_convert_text_edit(edit) do + edit + end + def full_range(source_file) do [_ | _] = lines = lines(source_file) diff --git a/apps/language_server/test/providers/code_action/replace_remote_function_test.exs b/apps/language_server/test/providers/code_action/replace_remote_function_test.exs new file mode 100644 index 000000000..5a38ce9ad --- /dev/null +++ b/apps/language_server/test/providers/code_action/replace_remote_function_test.exs @@ -0,0 +1,346 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeAction.ReplaceRemoteFunctionTest do + use ElixirLS.LanguageServer.Test.CodeMod.Case + + alias ElixirLS.LanguageServer.Providers.CodeAction.ReplaceRemoteFunction + alias ElixirLS.LanguageServer.SourceFile + + import ElixirLS.LanguageServer.Protocol + + @default_message """ + Enum.counts/1 is undefined or private. Did you mean: + + * concat/1 + * concat/2 + * count/1 + * count/2 + """ + + def apply_code_mod(original_text, options) do + line_number = Keyword.get(options, :line, 0) + + source_file = %SourceFile{text: original_text, version: 0} + uri = "file:///file.ex" + + message = Keyword.get(options, :message, @default_message) + range = range(line_number, 0, line_number + 1, 0) + + diagnostics = [ + %{ + "message" => message, + "range" => range + } + ] + + changes = + source_file + |> ReplaceRemoteFunction.apply(uri, diagnostics) + |> Enum.map(& &1.edit.changes[uri]) + + {:ok, changes} + end + + def filter_edited_texts(edited_texts, options) do + suggestion = Keyword.get(options, :suggestion, "Enum.count") + + filtered_texts = Enum.filter(edited_texts, &String.contains?(&1, suggestion)) + + {:ok, filtered_texts} + end + + describe "fixes function call" do + test "applied to a standalone call" do + {:ok, [result]} = + ~q{ + Enum.counts([1, 2, 3]) + } + |> modify() + + assert result == "Enum.count([1, 2, 3])" + end + + test "applied to a variable match" do + {:ok, [result]} = + ~q{ + x = Enum.counts([1, 2, 3]) + } + |> modify() + + assert result == "x = Enum.count([1, 2, 3])" + end + + test "applied to a variable match, preserves comments" do + {:ok, [result]} = + ~q{ + x = Enum.counts([1, 2, 3]) # TODO: Fix this + } + |> modify() + + assert result == "x = Enum.count([1, 2, 3]) # TODO: Fix this" + end + + test "not changing variable name" do + {:ok, [result]} = + ~q{ + counts = Enum.counts([1, 2, 3]) + } + |> modify() + + assert result == "counts = Enum.count([1, 2, 3])" + end + + test "applied to a call after a pipe" do + {:ok, [result]} = + ~q{ + [1, 2, 3] |> Enum.counts() + } + |> modify() + + assert result == "[1, 2, 3] |> Enum.count()" + end + + test "changing only a function from provided possible modules" do + {:ok, [result]} = + ~q{ + Enumerable.counts([1, 2, 3]) + Enum.counts([3, 2, 1]) + } + |> modify() + + assert result == "Enumerable.counts([1, 2, 3]) + Enum.count([3, 2, 1])" + end + + test "changing all occurrences of the function in the line" do + {:ok, [result]} = + ~q{ + Enum.counts([1, 2, 3]) + Enum.counts([3, 2, 1]) + } + |> modify() + + assert result == "Enum.count([1, 2, 3]) + Enum.count([3, 2, 1])" + end + + test "applied in a comprehension" do + {:ok, [result]} = + ~q{ + for x <- Enum.counts([[1], [2], [3]]), do: Enum.counts([[1], [2], [3], [x]]) + } + |> modify(suggestion: "Enum.concat") + + assert result == + "for x <- Enum.concat([[1], [2], [3]]), do: Enum.concat([[1], [2], [3], [x]])" + end + + test "applied in a with block" do + {:ok, [result]} = + ~q{ + with x <- Enum.counts([1, 2, 3]), do: x + } + |> modify() + + assert result == "with x <- Enum.count([1, 2, 3]), do: x" + end + + test "applied in a with block, preserves comment" do + {:ok, [result]} = + ~q{ + with x <- Enum.counts([1, 2, 3]), do: x # TODO: Fix this + } + |> modify() + + assert result == "with x <- Enum.count([1, 2, 3]), do: x # TODO: Fix this" + end + + test "applied in a with block with started do end" do + {:ok, [result]} = + ~q{ + with x <- Enum.counts([1, 2, 3]) do + } + |> modify() + + assert result == "with x <- Enum.count([1, 2, 3]) do" + end + + test "preserving the leading indent" do + {:ok, [result]} = modify(" Enum.counts([1, 2, 3])", trim: false) + + assert result == " Enum.count([1, 2, 3])" + end + + test "handles erlang functions" do + message = """ + :ets.inserd/2 is undefined or private. Did you mean: + * insert/2 + * insert_new/2 + """ + + {:ok, [result]} = + ~q{ + :ets.inserd(a, b) + } + |> modify(message: message, suggestion: ":ets.insert(a, b)") + + assert result == ":ets.insert(a, b)" + end + + test "when aliased" do + message = """ + ElixirLS.Test.RemoteFunction.fou/1 is undefined or private. Did you mean: + + * foo/1 + """ + + {:ok, [result]} = + ~q{ + alias ElixirLS.Test.RemoteFunction + RemoteFunction.fou(42) + } + |> modify(message: message, suggestion: "RemoteFunction.foo", line: 1) + + assert result == "alias ElixirLS.Test.RemoteFunction\nRemoteFunction.foo(42)" + end + + test "when aliased with a custom name" do + message = """ + ElixirLS.Test.RemoteFunction.fou/1 is undefined or private. Did you mean: + + * foo/1 + """ + + {:ok, [result]} = + ~q{ + alias ElixirLS.Test.RemoteFunction, as: Remote + Remote.fou(42) + } + |> modify(message: message, suggestion: "Remote.foo", line: 1) + + assert result == "alias ElixirLS.Test.RemoteFunction, as: Remote\nRemote.foo(42)" + end + end + + describe "fixes captured function" do + test "applied to a standalone function" do + {:ok, [result]} = + ~q[ + &Enum.counts/1 + ] + |> modify() + + assert result == "&Enum.count/1" + end + + test "applied to a variable match" do + {:ok, [result]} = + ~q[ + x = &Enum.counts/1 + ] + |> modify() + + assert result == "x = &Enum.count/1" + end + + test "applied to a variable match, preserves comments" do + {:ok, [result]} = + ~q[ + x = &Enum.counts/1 # TODO: Fix this + ] + |> modify() + + assert result == "x = &Enum.count/1 # TODO: Fix this" + end + + test "not changing variable name" do + {:ok, [result]} = + ~q{ + counts = &Enum.counts/1 + } + |> modify() + + assert result == "counts = &Enum.count/1" + end + + test "applied to an argument" do + {:ok, [result]} = + ~q{ + [[1, 2], [3, 4]] |> Enum.map(&Enum.counts/1) + } + |> modify() + + assert result == "[[1, 2], [3, 4]] |> Enum.map(&Enum.count/1)" + end + + test "changing only a function from provided possible modules" do + {:ok, [result]} = + ~q{ + [&Enumerable.counts/1, &Enum.counts/1] + } + |> modify() + + assert result == "[&Enumerable.counts/1, &Enum.count/1]" + end + + test "changing all occurrences of the function in the line" do + {:ok, [result]} = + ~q{ + [&Enum.counts/1, &Enum.counts/1] + } + |> modify() + + assert result == "[&Enum.count/1, &Enum.count/1]" + end + + test "preserving the leading indent" do + {:ok, [result]} = modify(" &Enum.counts/1", trim: false) + + assert result == " &Enum.count/1" + end + + test "handles erlang functions" do + message = """ + :ets.inserd/2 is undefined or private. Did you mean: + * insert/2 + * insert_new/2 + """ + + {:ok, [result]} = + ~q{ + &:ets.inserd/2 + } + |> modify(message: message, suggestion: ":ets.insert/2") + + assert result == "&:ets.insert/2" + end + + test "when aliased" do + message = """ + ElixirLS.Test.RemoteFunction.fou/1 is undefined or private. Did you mean: + + * foo/1 + """ + + {:ok, [result]} = + ~q{ + alias ElixirLS.Test.RemoteFunction + &RemoteFunction.fou/1 + } + |> modify(message: message, suggestion: "RemoteFunction.foo", line: 1) + + assert result == "alias ElixirLS.Test.RemoteFunction\n&RemoteFunction.foo/1" + end + + test "when aliased with a custom name" do + message = """ + ElixirLS.Test.RemoteFunction.fou/1 is undefined or private. Did you mean: + + * foo/1 + """ + + {:ok, [result]} = + ~q{ + alias ElixirLS.Test.RemoteFunction, as: Remote + &Remote.fou/1 + } + |> modify(message: message, suggestion: "Remote.foo", line: 1) + + assert result == "alias ElixirLS.Test.RemoteFunction, as: Remote\n&Remote.foo/1" + end + end +end diff --git a/apps/language_server/test/providers/code_action/replace_with_underscore_test.exs b/apps/language_server/test/providers/code_action/replace_with_underscore_test.exs new file mode 100644 index 000000000..13dcb4b43 --- /dev/null +++ b/apps/language_server/test/providers/code_action/replace_with_underscore_test.exs @@ -0,0 +1,265 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeAction.ReplaceWithUnderscoreTest do + use ElixirLS.LanguageServer.Test.CodeMod.Case + + alias ElixirLS.LanguageServer.Providers.CodeAction.ReplaceWithUnderscore + alias ElixirLS.LanguageServer.SourceFile + + import ElixirLS.LanguageServer.Protocol + + def apply_code_mod(original_text, options) do + variable = Keyword.get(options, :variable, :unused) + line_number = Keyword.get(options, :line, 0) + + source_file = %SourceFile{text: original_text, version: 0} + uri = "file:///file.ex" + + message = + """ + variable "#{variable}" is unused (if the variable is not meant to be used, prefix it with an underscore) + """ + |> String.trim() + + range = range(line_number, 0, line_number + 1, 0) + + diagnostics = [ + %{ + "message" => message, + "range" => range + } + ] + + changes = + source_file + |> ReplaceWithUnderscore.apply(uri, diagnostics) + |> Enum.map(& &1.edit.changes[uri]) + + {:ok, changes} + end + + describe "fixes in parameters" do + test "applied to an unadorned param" do + {:ok, [result]} = + ~q[ + def my_func(unused) do + ] + |> modify() + + assert result == "def my_func(_unused) do" + end + + test "applied to a pattern match in params" do + {:ok, [result]} = + ~q[ + def my_func(%SourceFile{} = unused) do + ] + |> modify() + + assert result == "def my_func(%SourceFile{} = _unused) do" + end + + test "applied to a pattern match preceding a struct in params" do + {:ok, [result]} = + ~q[ + def my_func(unused = %SourceFile{}) do + ] + |> modify() + + assert result == "def my_func(_unused = %SourceFile{}) do" + end + + test "applied prior to a map" do + {:ok, [result]} = + ~q[ + def my_func(unused = %{}) do + ] + |> modify() + + assert result == "def my_func(_unused = %{}) do" + end + + test "applied after a map %{} = unused" do + {:ok, [result]} = + ~q[ + def my_func(%{} = unused) do + ] + |> modify() + + assert result == "def my_func(%{} = _unused) do" + end + + test "applied to a map key %{foo: unused}" do + {:ok, [result]} = + ~q[ + def my_func(%{foo: unused}) do + ] + |> modify() + + assert result == "def my_func(%{foo: _unused}) do" + end + + test "applied to a list element params = [unused, a, b | rest]" do + {:ok, [result]} = + ~q{ + def my_func([unused, a, b | rest]) do + } + |> modify() + + assert result == "def my_func([_unused, a, b | rest]) do" + end + + test "applied to the tail of a list params = [a, b, | unused]" do + {:ok, [result]} = + ~q{ + def my_func([a, b | unused]) do + } + |> modify() + + assert result == "def my_func([a, b | _unused]) do" + end + + test "does not change the name of a function if it is the same as a parameter" do + {:ok, [result]} = + ~q{ + def unused(unused) do + end + } + |> modify() + + assert result == "def unused(_unused) do\nend" + end + end + + describe "fixes in variables" do + test "applied to a variable match " do + {:ok, [result]} = + ~q[ + x = 3 + ] + |> modify(variable: "x") + + assert result == "_x = 3" + end + + test "applied to a variable match, preserves comments" do + {:ok, [result]} = + ~q[ + unused = bar # TODO: Fix this + ] + |> modify() + + assert result == "_unused = bar # TODO: Fix this" + end + + test "preserves spacing" do + {:ok, [result]} = + " x = 3" + |> modify(variable: "x", trim: false) + + assert result == " _x = 3" + end + + test "applied to a variable with a pattern matched struct" do + {:ok, [result]} = + ~q[ + unused = %Struct{} + ] + |> modify() + + assert result == "_unused = %Struct{}" + end + + test "applied to a variable with a pattern matched struct preserves trailing comments" do + {:ok, [result]} = + ~q[ + unused = %Struct{} # TODO: fix + ] + |> modify() + + assert result == "_unused = %Struct{} # TODO: fix" + end + + test "applied to struct param matches" do + {:ok, [result]} = + ~q[ + %Struct{field: unused, other_field: used} + ] + |> modify() + + assert result == "%Struct{field: _unused, other_field: used}" + end + + test "applied to a struct module match %module{}" do + {:ok, [result]} = + ~q[ + %unused{field: first, other_field: used} + ] + |> modify() + + assert result == "%_unused{field: first, other_field: used}" + end + + test "applied to a tuple value" do + {:ok, [result]} = + ~q[ + {a, b, unused, c} = whatever + ] + |> modify() + + assert result == "{a, b, _unused, c} = whatever" + end + + test "applied to a list element" do + {:ok, [result]} = + ~q{ + [a, b, unused, c] = whatever + } + |> modify() + + assert result == "[a, b, _unused, c] = whatever" + end + + test "applied to map value" do + {:ok, [result]} = + ~q[ + %{foo: a, bar: unused} = whatever + ] + |> modify() + + assert result == "%{foo: a, bar: _unused} = whatever" + end + end + + describe "fixes in structures" do + test "applied to a match of a comprehension" do + {:ok, [result]} = + ~q[ + for {unused, something_else} <- my_enum, do: something_else + ] + |> modify() + + assert result == "for {_unused, something_else} <- my_enum, do: something_else" + end + + test "applied to a match in a with block" do + {:ok, [result]} = + ~q[ + with {unused, something_else} <- my_enum, do: something_else + ] + |> modify() + + assert result == "with {_unused, something_else} <- my_enum, do: something_else" + end + end + + test "it preserves the leading indent" do + {:ok, [result]} = modify(" {foo, unused, bar}", trim: false) + + assert result == " {foo, _unused, bar}" + end + + test "it preserves a comment" do + {:ok, [result]} = modify("{foo, unused, bar} # TODO Fix this") + + assert result == "{foo, _unused, bar} # TODO Fix this" + end +end diff --git a/apps/language_server/test/providers/code_mod/diff_test.exs b/apps/language_server/test/providers/code_mod/diff_test.exs new file mode 100644 index 000000000..1c6dd19de --- /dev/null +++ b/apps/language_server/test/providers/code_mod/diff_test.exs @@ -0,0 +1,233 @@ +defmodule ElixirLS.LanguageServer.Providers.CodeMod.DiffTest do + use ElixirLS.LanguageServer.Test.CodeMod.Case + + alias ElixirLS.LanguageServer.Protocol.TextEdit + + import ElixirLS.LanguageServer.Providers.CodeMod.Diff + import ElixirLS.LanguageServer.Protocol + + def edit(start_line, start_code_unit, end_line, end_code_unit, replacement) do + %TextEdit{ + newText: replacement, + range: range(start_line, start_code_unit, end_line, end_code_unit) + } + end + + def apply_code_mod(source, opts) do + result = Keyword.get(opts, :result) + {:ok, [diff(source, result)]} + end + + def assert_edited(initial, final) do + assert {:ok, [edited]} = modify(initial, result: final) + assert edited == final + end + + describe "single line ascii diffs" do + test "a deletion at the start" do + orig = " hello" + final = "hello" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 0, 0, 2, "") + assert_edited(orig, final) + end + + test "appending in the middle" do + orig = "hello" + final = "heyello" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 2, 0, 2, "ye") + assert_edited(orig, final) + end + + test "deleting in the middle" do + orig = "hello" + final = "heo" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 2, 0, 4, "") + assert_edited(orig, final) + end + + test "inserting after a delete" do + orig = "hello" + final = "helvetica went" + + # this is collapsed into a single edit of an + # insert that spans the delete and the insert + assert [edit] = diff(orig, final) + assert edit == edit(0, 3, 0, 5, "vetica went") + assert_edited(orig, final) + end + + test "edits are ordered back to front on a line" do + orig = "hello there" + final = "hellothe" + + assert [e1, e2] = diff(orig, final) + assert e1 == edit(0, 9, 0, 11, "") + assert e2 == edit(0, 5, 0, 6, "") + end + end + + describe "applied edits" do + test "multiple edits on the same line don't conflict" do + orig = "foo( a, b)" + expected = "foo(a, b)" + + assert_edited(orig, expected) + end + end + + describe "multi line ascii diffs" do + test "multi-line deletion at the start" do + orig = + """ + none + two + hello + """ + |> String.trim() + + final = "hello" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 0, 2, 0, "") + assert_edited(orig, final) + end + + test "multi-line appending in the middle" do + orig = "hello" + final = "he\n\n ye\n\nllo" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 2, 0, 2, "\n\n ye\n\n") + assert_edited(orig, final) + end + + test "deleting multiple lines in the middle" do + orig = + """ + hello + there + people + goodbye + """ + |> String.trim() + + final = "hellogoodbye" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 5, 3, 0, "") + assert_edited(orig, final) + end + + test "deleting multiple lines" do + orig = ~q[ + foo(a, + b, + c, + d) + ] + + final = ~q[ + foo(a, b, c, d) + ]t + + assert_edited(orig, final) + end + + test "deletions keep indentation" do + orig = + """ + hello + there + + + people + """ + |> String.trim() + + final = + """ + hello + there + people + """ + |> String.trim() + + assert [edit] = diff(orig, final) + assert edit == edit(2, 0, 4, 0, "") + assert_edited(orig, final) + end + end + + describe "single line emoji" do + test "deleting after" do + orig = ~S[{"🎸", "after"}] + final = ~S[{"🎸", "after"}] + + assert [edit] = diff(orig, final) + assert edit == edit(0, 7, 0, 9, "") + assert_edited(orig, final) + end + + test "inserting in the middle" do + orig = ~S[🎸🎸] + final = ~S[🎸🎺🎸] + + assert [edit] = diff(orig, final) + assert edit == edit(0, 2, 0, 2, "🎺") + assert_edited(orig, final) + end + + test "deleting in the middle" do + orig = ~S[🎸🎺🎺🎸] + final = ~S[🎸🎸] + + assert [edit] = diff(orig, final) + assert edit == edit(0, 2, 0, 6, "") + assert_edited(orig, final) + end + + test "multiple deletes on the same line" do + orig = ~S[🎸a 🎺b 🎺c 🎸] + final = ~S[🎸ab🎸] + + assert_edited(orig, final) + end + end + + describe("multi line emoji") do + test "deleting on the first line" do + orig = ~q[ + 🎸a 🎺b 🎺c 🎸 + hello + ]t + + final = ~q[ + 🎸a b c 🎸 + hello + ]t + + assert_edited(orig, final) + end + + test "deleting on subsequent lines" do + orig = ~q[ + 🎸a 🎺b 🎺c 🎸 + hello + 🎸a 🎺b 🎺c 🎸 + ]t + final = ~q[ + 🎸a 🎺b 🎺c 🎸 + ello + 🎸a 🎺b 🎺c 🎸 + ]t + + assert_edited(orig, final) + end + end +end diff --git a/apps/language_server/test/support/code_mode_case.ex b/apps/language_server/test/support/code_mode_case.ex new file mode 100644 index 000000000..d81fce8a0 --- /dev/null +++ b/apps/language_server/test/support/code_mode_case.ex @@ -0,0 +1,88 @@ +defmodule ElixirLS.LanguageServer.Test.CodeMod.Case do + alias ElixirLS.LanguageServer.SourceFile + + use ExUnit.CaseTemplate + + using do + quote do + import unquote(__MODULE__), only: [sigil_q: 2] + + def apply_code_mod(_original_text, _options) do + {:error, "You must implement apply_code_mod/2"} + end + + def filter_edited_texts(edited_texts, _options) do + {:ok, edited_texts} + end + + defoverridable apply_code_mod: 2, filter_edited_texts: 2 + + def modify(original, options \\ []) do + with {:ok, changes} <- apply_code_mod(original, options) do + original + |> unquote(__MODULE__).apply_changes(changes, options) + |> filter_edited_texts(options) + end + end + end + end + + def apply_changes(original_text, changes, opts) do + Enum.map(changes, fn text_edits -> + %SourceFile{text: edited_text} = + %SourceFile{text: original_text, version: 0} + |> SourceFile.apply_content_changes(text_edits) + + if Keyword.get(opts, :trim, true) do + String.trim(edited_text) + else + edited_text + end + end) + end + + def sigil_q(text, opts \\ []) do + {first, rest} = + case String.split(text, "\n") do + ["", first | rest] -> {first, rest} + [first | rest] -> {first, rest} + end + + base_indent = indent(first) + indent_length = String.length(base_indent) + + [first | rest] + |> Enum.map_join("\n", &strip_leading_indent(&1, indent_length)) + |> maybe_trim(opts) + end + + @indent_re ~r/^\s*/ + defp indent(first_line) do + case Regex.scan(@indent_re, first_line) do + [[indent]] -> indent + _ -> "" + end + end + + defp strip_leading_indent(s, 0) do + s + end + + defp strip_leading_indent(<<" ", rest::binary>>, count) when count > 0 do + strip_leading_indent(rest, count - 1) + end + + defp strip_leading_indent(s, _) do + s + end + + defp maybe_trim(iodata, [?t]) do + iodata + |> IO.iodata_to_binary() + |> String.trim_trailing() + end + + defp maybe_trim(iodata, _) do + IO.iodata_to_binary(iodata) + end +end diff --git a/apps/language_server/test/support/fixtures/remote_function.ex b/apps/language_server/test/support/fixtures/remote_function.ex new file mode 100644 index 000000000..aff716c5a --- /dev/null +++ b/apps/language_server/test/support/fixtures/remote_function.ex @@ -0,0 +1,9 @@ +defmodule ElixirLS.Test.RemoteFunction do + def foo(a) do + a + end + + def bar(a) do + a + end +end