From 7f6a8b3d59d725ef491c45523115038671cf0b8a Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Fri, 28 Oct 2022 09:41:09 -0700 Subject: [PATCH 01/21] Experimental project structure This commit represents a new structure for the experimental project, and a path forward. With these changes the project now has: * A build option enabling the experimental server * Per-message routing for the experimental server. If enabled, it can either share messages with the existing server or take them over. Presently, the find references and formatting providers are implemented and "exclusive", meaning that they're handled solely by the experimental server * A consistent interface for building providers. * A consistent way to convert lsp messages into data structures and back again. This conversion is handled automatically for providers. * A genserver-like interface for providers to implement * Data structures representing LSP messages that are simple to define and build. * Fast and efficient conversion between utf8 and utf16. * A separation between what a provider does and how it responds to messages. This allows the work that underpins providers to be tested independently from the language server. --- apps/language_server/.formatter.exs | 4 +- apps/language_server/lib/language_server.ex | 46 ++- .../language_server/experimental/code_unit.ex | 159 +++++++++ .../language_server/experimental/format.ex | 158 +++++++++ .../experimental/format/diff.ex | 102 ++++++ .../experimental/language_server.ex | 68 ++++ .../lib/language_server/experimental/log.ex | 15 + .../experimental/process_cache.ex | 82 +++++ .../language_server/experimental/project.ex | 262 ++++++++++++++ .../experimental/protocol/id.ex | 7 + .../experimental/protocol/notifications.ex | 20 +- .../experimental/protocol/proto.ex | 6 +- .../experimental/protocol/proto/convert.ex | 11 +- .../experimental/protocol/proto/decoders.ex | 28 ++ .../experimental/protocol/proto/field.ex | 2 +- .../experimental/protocol/proto/lsp_types.ex | 16 + .../protocol/proto/macros/inspect.ex | 36 ++ .../protocol/proto/macros/message.ex | 10 +- .../protocol/proto/notification.ex | 6 +- .../experimental/protocol/proto/request.ex | 29 +- .../experimental/protocol/proto/requests.ex | 0 .../experimental/protocol/proto/type.ex | 2 + .../experimental/protocol/requests.ex | 36 +- .../experimental/protocol/responses.ex | 8 +- .../experimental/protocol/types.ex | 22 +- .../experimental/provider/env.ex | 22 ++ .../provider/handlers/find_references.ex | 58 ++++ .../provider/handlers/formatting.ex | 22 ++ .../experimental/provider/queue.ex | 228 ++++++++++++ .../experimental/provider/supervisor.ex | 20 ++ .../language_server/experimental/server.ex | 55 ++- .../experimental/server/configuration.ex | 137 ++++++++ .../server/configuration/support.ex | 49 +++ .../experimental/server/state.ex | 48 ++- .../experimental/source_file.ex | 27 +- .../experimental/source_file/conversions.ex | 76 ++-- .../experimental/source_file/store.ex | 128 ++++++- .../experimental/supervisor.ex | 21 ++ .../lib/language_server/packet_router.ex | 2 +- .../lib/language_server/server.ex | 43 ++- .../lib/language_server/server/decider.ex | 24 ++ apps/language_server/mix.exs | 2 +- .../test/experimental/code_unit_test.exs | 203 +++++++++++ .../test/experimental/format/diff_test.exs | 149 ++++++++ .../test/experimental/formatter_test.exs | 74 ++++ .../test/experimental/process_cache_test.exs | 58 ++++ .../test/experimental/project_test.exs | 325 ++++++++++++++++++ .../test/experimental/protocol/proto_test.exs | 30 +- .../handlers/find_references_test.exs | 234 +++++++++++++ .../provider/handlers/formatting_test.exs | 3 + .../test/experimental/provider/queue_test.exs | 101 ++++++ .../server/configuration_test.exs | 272 +++++++++++++++ .../test/experimental/server/state_test.exs | 16 +- .../experimental/source_file/store_test.exs | 46 +++ .../test/experimental/source_file_test.exs | 70 +++- .../test/support/fixtures/lsp_protocol.ex | 14 +- config/config.exs | 12 + 57 files changed, 3557 insertions(+), 147 deletions(-) create mode 100644 apps/language_server/lib/language_server/experimental/code_unit.ex create mode 100644 apps/language_server/lib/language_server/experimental/format.ex create mode 100644 apps/language_server/lib/language_server/experimental/format/diff.ex create mode 100644 apps/language_server/lib/language_server/experimental/language_server.ex create mode 100644 apps/language_server/lib/language_server/experimental/log.ex create mode 100644 apps/language_server/lib/language_server/experimental/process_cache.ex create mode 100644 apps/language_server/lib/language_server/experimental/project.ex create mode 100644 apps/language_server/lib/language_server/experimental/protocol/id.ex create mode 100644 apps/language_server/lib/language_server/experimental/protocol/proto/macros/inspect.ex delete mode 100644 apps/language_server/lib/language_server/experimental/protocol/proto/requests.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/env.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/handlers/find_references.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/handlers/formatting.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/queue.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/supervisor.ex create mode 100644 apps/language_server/lib/language_server/experimental/server/configuration.ex create mode 100644 apps/language_server/lib/language_server/experimental/server/configuration/support.ex create mode 100644 apps/language_server/lib/language_server/experimental/supervisor.ex create mode 100644 apps/language_server/lib/language_server/server/decider.ex create mode 100644 apps/language_server/test/experimental/code_unit_test.exs create mode 100644 apps/language_server/test/experimental/format/diff_test.exs create mode 100644 apps/language_server/test/experimental/formatter_test.exs create mode 100644 apps/language_server/test/experimental/process_cache_test.exs create mode 100644 apps/language_server/test/experimental/project_test.exs create mode 100644 apps/language_server/test/experimental/provider/handlers/find_references_test.exs create mode 100644 apps/language_server/test/experimental/provider/handlers/formatting_test.exs create mode 100644 apps/language_server/test/experimental/provider/queue_test.exs create mode 100644 apps/language_server/test/experimental/server/configuration_test.exs diff --git a/apps/language_server/.formatter.exs b/apps/language_server/.formatter.exs index 950259bb6..2cfd23661 100644 --- a/apps/language_server/.formatter.exs +++ b/apps/language_server/.formatter.exs @@ -6,12 +6,14 @@ impossible_to_format = [ proto_dsl = [ defenum: 1, defnotification: 2, - defrequest: 2, + defnotification: 3, + defrequest: 3, defresponse: 1, deftype: 1 ] [ + import_deps: [:patch], export: [ locals_without_parens: proto_dsl ], diff --git a/apps/language_server/lib/language_server.ex b/apps/language_server/lib/language_server.ex index 2c5891023..140602269 100644 --- a/apps/language_server/lib/language_server.ex +++ b/apps/language_server/lib/language_server.ex @@ -12,18 +12,19 @@ defmodule ElixirLS.LanguageServer do @impl Application def start(_type, _args) do - children = [ - Experimental.SourceFile.Store, - {ElixirLS.LanguageServer.Server, ElixirLS.LanguageServer.Server}, - Experimental.Server, - {ElixirLS.LanguageServer.PacketRouter, - [LanguageServer.Server] ++ @maybe_experimental_server}, - {ElixirLS.LanguageServer.JsonRpc, - name: ElixirLS.LanguageServer.JsonRpc, language_server: LanguageServer.PacketRouter}, - {ElixirLS.LanguageServer.Providers.WorkspaceSymbols, []}, - {ElixirLS.LanguageServer.Tracer, []}, - {ElixirLS.LanguageServer.ExUnitTestTracer, []} - ] + Experimental.LanguageServer.persist_enabled_state() + + children = + [ + maybe_experimental_supervisor(), + {ElixirLS.LanguageServer.Server, ElixirLS.LanguageServer.Server}, + maybe_packet_router(), + jsonrpc(), + {ElixirLS.LanguageServer.Providers.WorkspaceSymbols, []}, + {ElixirLS.LanguageServer.Tracer, []}, + {ElixirLS.LanguageServer.ExUnitTestTracer, []} + ] + |> Enum.reject(&is_nil/1) opts = [strategy: :one_for_one, name: LanguageServer.Supervisor, max_restarts: 0] Supervisor.start_link(children, opts) @@ -42,4 +43,25 @@ defmodule ElixirLS.LanguageServer do :ok end + + defp maybe_experimental_supervisor do + if Experimental.LanguageServer.enabled?() do + Experimental.Supervisor + end + end + + defp maybe_packet_router do + if Experimental.LanguageServer.enabled?() do + {ElixirLS.LanguageServer.PacketRouter, [LanguageServer.Server, Experimental.Server]} + end + end + + defp jsonrpc do + if Experimental.LanguageServer.enabled?() do + {ElixirLS.LanguageServer.JsonRpc, + name: ElixirLS.LanguageServer.JsonRpc, language_server: LanguageServer.PacketRouter} + else + {ElixirLS.LanguageServer.JsonRpc, name: ElixirLS.LanguageServer.JsonRpc} + end + end end diff --git a/apps/language_server/lib/language_server/experimental/code_unit.ex b/apps/language_server/lib/language_server/experimental/code_unit.ex new file mode 100644 index 000000000..6d567b9ee --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/code_unit.ex @@ -0,0 +1,159 @@ +defmodule ElixirLS.LanguageServer.Experimental.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 + 1, 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 + 1, 0) + end + + # Private + + # UTF-16 + + 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 - 1} + 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 - 1} + 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/experimental/format.ex b/apps/language_server/lib/language_server/experimental/format.ex new file mode 100644 index 000000000..64b749fc8 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/format.ex @@ -0,0 +1,158 @@ +defmodule ElixirLS.LanguageServer.Experimental.Format do + alias ElixirLS.LanguageServer.Experimental.Format.Diff + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit + + require Logger + @type formatter_function :: (String.t() -> any) | nil + + @spec text_edits(SourceFile.t(), String.t() | nil) :: {:ok, [TextEdit.t()]} | {:error, any} + def text_edits(%SourceFile{} = document, project_path_or_uri) do + with {:ok, unformatted, formatted} <- do_format(document, project_path_or_uri) do + edits = Diff.diff(unformatted, formatted) + {:ok, edits} + end + end + + @spec format(SourceFile.t(), String.t() | nil) :: {:ok, String.t()} | {:error, any} + def format(%SourceFile{} = document, project_path_or_uri) do + with {:ok, _, formatted_code} <- do_format(document, project_path_or_uri) do + {:ok, formatted_code} + end + end + + defp do_format(%SourceFile{} = document, project_path_or_uri) + when is_binary(project_path_or_uri) do + project_path = Conversions.ensure_path(project_path_or_uri) + + with :ok <- check_current_directory(document, project_path), + {:ok, formatter, options} <- formatter_for(document.path), + :ok <- + check_inputs_apply(document, project_path, Keyword.get(options, :inputs)) do + document + |> SourceFile.to_string() + |> formatter.() + end + end + + defp do_format(%SourceFile{} = document, _) do + formatter = build_formatter([]) + + document + |> SourceFile.to_string() + |> formatter.() + end + + @spec formatter_for(String.t()) :: {:ok, formatter_function, keyword()} | :error + defp formatter_for(uri_or_path) do + path = Conversions.ensure_path(uri_or_path) + + try do + true = Code.ensure_loaded?(Mix.Tasks.Format) + + if function_exported?(Mix.Tasks.Format, :formatter_for_file, 1) do + {formatter_function, options} = Mix.Tasks.Format.formatter_for_file(path) + + wrapped_formatter_function = wrap_with_try_catch(formatter_function) + + {:ok, wrapped_formatter_function, options} + else + options = Mix.Tasks.Format.formatter_opts_for_file(path) + formatter = build_formatter(options) + {:ok, formatter, Mix.Tasks.Format.formatter_opts_for_file(path)} + end + rescue + e -> + message = Exception.message(e) + + Logger.warn( + "Unable to get formatter options for #{path}: #{inspect(e.__struct__)} #{message}" + ) + + {:error, :no_formatter_available} + end + end + + defp build_formatter(opts) do + fn code -> + formatted_iodata = Code.format_string!(code, opts) + IO.iodata_to_binary([formatted_iodata, ?\n]) + end + |> wrap_with_try_catch() + end + + defp wrap_with_try_catch(formatter_fn) do + fn code -> + try do + {:ok, code, formatter_fn.(code)} + rescue + e -> + {:error, e} + end + end + end + + defp check_current_directory(%SourceFile{} = document, project_path) do + cwd = File.cwd!() + + if subdirectory?(document.path, parent: project_path) or + subdirectory?(document.path, parent: cwd) do + :ok + else + message = + "Cannot format file from current directory " <> + "(Currently in #{Path.relative_to(cwd, project_path)})" + + {:error, message} + end + end + + defp check_inputs_apply(%SourceFile{} = document, project_path, inputs) + when is_list(inputs) do + formatter_dir = dominating_formatter_exs_dir(document, project_path) + + inputs_apply? = + Enum.any?(inputs, fn input_glob -> + glob = Path.join(formatter_dir, input_glob) + PathGlobVendored.match?(document.path, glob, match_dot: true) + end) + + if inputs_apply? do + :ok + else + {:error, :input_mismatch} + end + end + + defp check_inputs_apply(_, _, _), do: :ok + + defp subdirectory?(child, parent: parent) do + normalized_parent = Path.absname(parent) + String.starts_with?(child, normalized_parent) + end + + # Finds the directory with the .formatter.exs that's the nearest parent to the + # source file, or the project dir if none was found. + defp dominating_formatter_exs_dir(%SourceFile{} = document, project_path) do + document.path + |> Path.dirname() + |> dominating_formatter_exs_dir(project_path) + end + + defp dominating_formatter_exs_dir(project_dir, project_dir) do + project_dir + end + + defp dominating_formatter_exs_dir(current_dir, project_path) do + formatter_exs_name = Path.join(current_dir, ".formatter.exs") + + if File.exists?(formatter_exs_name) do + current_dir + else + current_dir + |> Path.dirname() + |> dominating_formatter_exs_dir(project_path) + end + end +end diff --git a/apps/language_server/lib/language_server/experimental/format/diff.ex b/apps/language_server/lib/language_server/experimental/format/diff.ex new file mode 100644 index 000000000..4e7a84783 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/format/diff.ex @@ -0,0 +1,102 @@ +defmodule ElixirLS.LanguageServer.Experimental.Format.Diff do + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Position + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit + + @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 + {_, edits} = + Enum.reduce(difference, {{0, 0}, []}, fn {diff_type, diff_string}, {position, edits} -> + apply_diff(diff_type, position, diff_string, edits) + end) + + Enum.reduce(edits, [], &collapse/2) + end + + # This collapses a delete and an an insert that are adjacent to one another + # into a single insert, changing the delete to insert the text from the + # insert rather than "" + # It's a small optimization, but it was in the original + defp collapse( + %TextEdit{ + new_text: "", + range: %Range{ + end: %Position{character: same_character, line: same_line} + } + } = delete_edit, + [ + %TextEdit{ + new_text: insert_text, + range: + %Range{ + start: %Position{character: same_character, line: same_line} + } = _insert_edit + } + | rest + ] + ) + when byte_size(insert_text) > 0 do + collapsed_edit = %TextEdit{delete_edit | new_text: insert_text} + [collapsed_edit | rest] + end + + defp collapse(%TextEdit{} = edit, edits) do + [edit | edits] + end + + defp apply_diff(:eq, position, doc_string, edits) do + new_position = advance(doc_string, position) + {new_position, edits} + end + + defp apply_diff(:del, {line, code_unit} = position, change, edits) do + after_pos = {edit_end_line, edit_end_unit} = advance(change, position) + {after_pos, [edit("", line, code_unit, edit_end_line, edit_end_unit) | edits]} + end + + defp apply_diff(:ins, {line, code_unit} = position, change, edits) do + {advance(change, position), [edit(change, line, code_unit, line, code_unit) | edits]} + end + + def advance(<<>>, position) do + position + end + + for ending <- ["\r\n", "\r", "\n"] do + def advance(<>, {line, _unit}) do + advance(rest, {line + 1, 0}) + end + end + + def advance(<>, {line, unit}) when c < 128 do + advance(rest, {line, unit + 1}) + end + + def advance(<>, {line, unit}) do + increment = utf16_code_units(<>) + advance(rest, {line, unit + increment}) + end + + def utf16_code_units(<<_::utf16>> = utf16_grapheme) do + utf16_grapheme + |> byte_size() + |> div(2) + end + + defp edit(text, start_line, start_unit, end_line, end_unit) do + TextEdit.new( + new_text: text, + range: + Range.new( + start: Position.new(line: start_line, character: start_unit), + end: Position.new(line: end_line, character: end_unit) + ) + ) + end +end diff --git a/apps/language_server/lib/language_server/experimental/language_server.ex b/apps/language_server/lib/language_server/experimental/language_server.ex new file mode 100644 index 000000000..12166747c --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/language_server.ex @@ -0,0 +1,68 @@ +defmodule ElixirLS.LanguageServer.Experimental.LanguageServer do + alias ElixirLS.LanguageServer.Experimental.Protocol + + require Logger + + @type uri :: String.t() + + def handler_state(method) do + if enabled?() do + Map.get(handler_states(), method, :ignored) + else + :ignored + end + end + + @enabled Application.compile_env(:language_server, :enable_experimental_server, false) + + def persist_enabled_state do + set_enabled(@enabled) + end + + def set_enabled(value) do + if :persistent_term.get(:experimental_enabled?, nil) == nil do + spawn(fn -> + Process.sleep(5000) + + if value do + handled_messages = + Enum.map_join(handler_states(), "\n", fn {method, access} -> + "\t#{method}: #{access}" + end) + + "Experimental server is enabled. handling the following messages #{handled_messages}" + else + "Experimental server is disabled." + end + |> Logger.info() + end) + + :persistent_term.put(:experimental_enabled?, value) + end + end + + def enabled? do + :persistent_term.get(:experimental_enabled?, false) + end + + defp handler_states do + case :persistent_term.get(:handler_states, nil) do + nil -> + load_handler_states() + + states -> + states + end + end + + defp load_handler_states do + access_map = + Map.merge( + Protocol.Requests.__meta__(:access), + Protocol.Notifications.__meta__(:access) + ) + + :persistent_term.put(:handler_states, access_map) + access_map + end +end diff --git a/apps/language_server/lib/language_server/experimental/log.ex b/apps/language_server/lib/language_server/experimental/log.ex new file mode 100644 index 000000000..ca7e19141 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/log.ex @@ -0,0 +1,15 @@ +defmodule ElixirLS.LanguageServer.Experimental.Log do + defmacro log_and_time(label, do: block) do + quote do + require Logger + + {time_in_us, result} = + :timer.tc(fn -> + unquote(block) + end) + + Logger.info("#{unquote(label)} took #{Float.round(time_in_us / 1000, 2)}ms") + result + end + end +end diff --git a/apps/language_server/lib/language_server/experimental/process_cache.ex b/apps/language_server/lib/language_server/experimental/process_cache.ex new file mode 100644 index 000000000..fe7cb81e2 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/process_cache.ex @@ -0,0 +1,82 @@ +defmodule ElixirLS.LanguageServer.Experimental.ProcessCache do + @moduledoc """ + A simple cache with a timeout that lives in the process dictionary + """ + + defmodule Entry do + defstruct [:value, :expiry] + + def new(value, timeout_ms) do + expiry_ts = now_ts() + timeout_ms + %__MODULE__{value: value, expiry: expiry_ts} + end + + def valid?(%__MODULE__{} = entry) do + now_ts() < entry.expiry + end + + defp now_ts do + System.os_time(:millisecond) + end + end + + @type key :: term() + @type fetch_result :: {:ok, term()} | :error + + @doc """ + Retrieves a value from the cache + If the value is not found, the default is returned + """ + @spec get(key()) :: term() | nil + @spec get(key(), term()) :: term() | nil + def get(key, default \\ nil) do + case fetch(key) do + {:ok, val} -> val + :error -> default + end + end + + @doc """ + Retrieves a value from the cache + If the value is not found, the default is returned + """ + @spec fetch(key()) :: fetch_result() + def fetch(key) do + case Process.get(key, :unset) do + %Entry{} = entry -> + if Entry.valid?(entry) do + {:ok, entry.value} + else + Process.delete(key) + :error + end + + :unset -> + :error + end + end + + @doc """ + Retrieves and optionally sets a value in the cache. + + Trans looks up a value in the cache under key. If that value isn't + found, the compute_fn is then executed, and its return value is set + in the cache. The cached value will live in the cache for `timeout` + milliseconds + """ + def trans(key, timeout_ms \\ 5000, compute_fn) do + case fetch(key) do + :error -> + set(key, timeout_ms, compute_fn) + + {:ok, result} -> + result + end + end + + defp set(key, timeout_ms, compute_fn) do + value = compute_fn.() + Process.put(key, Entry.new(value, timeout_ms)) + value + end +end diff --git a/apps/language_server/lib/language_server/experimental/project.ex b/apps/language_server/lib/language_server/experimental/project.ex new file mode 100644 index 000000000..fc529f39e --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/project.ex @@ -0,0 +1,262 @@ +defmodule ElixirLS.LanguageServer.Experimental.Project do + @moduledoc """ + The representation of the current state of an elixir project. + + This struct contains all the information required to build a project and interrogate its configuration, + as well as business logic for how to change its attributes. + """ + alias ElixirLS.LanguageServer.SourceFile + alias ElixirLS.LanguageServer.Experimental.LanguageServer + + defstruct root_uri: nil, + working_uri: nil, + mix_exs_uri: nil, + mix_project?: false, + mix_env: nil, + mix_target: nil, + env_variables: nil + + @type message :: String.t() + @type restart_notification :: {:restart, Logger.level(), String.t()} + @type t :: %__MODULE__{ + root_uri: LanguageServer.uri(), + working_uri: LanguageServer.uri(), + mix_exs_uri: LanguageServer.uri(), + mix_env: atom(), + mix_target: atom(), + env_variables: %{String.t() => String.t()} + } + @type error_with_message :: {:error, message} + # Public + @spec new(LanguageServer.uri()) :: t + def new(root_uri) do + maybe_set_root_uri(%__MODULE__{}, root_uri) + end + + @spec root_path(t) :: Path.t() | nil + def root_path(%__MODULE__{root_uri: nil}) do + nil + end + + def root_path(%__MODULE__{} = project) do + SourceFile.Path.from_uri(project.root_uri) + end + + @spec project_path(t) :: Path.t() | nil + def project_path(%__MODULE__{working_uri: nil} = project) do + root_path(project) + end + + def project_path(%__MODULE__{working_uri: working_uri}) do + SourceFile.Path.from_uri(working_uri) + end + + @spec mix_exs_path(t) :: Path.t() | nil + def mix_exs_path(%__MODULE__{mix_exs_uri: nil}) do + nil + end + + def mix_exs_path(%__MODULE__{mix_exs_uri: mix_exs_uri}) do + SourceFile.Path.from_uri(mix_exs_uri) + end + + @spec change_mix_env(t, String.t() | nil) :: + {:ok, t} | error_with_message() | restart_notification() + def change_mix_env(%__MODULE__{} = project, mix_env) do + set_mix_env(project, mix_env) + end + + @spec change_mix_target(t, String.t() | nil) :: + {:ok, t} | error_with_message() | restart_notification() + def change_mix_target(%__MODULE__{} = project, mix_target) do + set_mix_target(project, mix_target) + end + + @spec change_project_directory(t, String.t() | nil) :: + {:ok, t} | error_with_message() | restart_notification() + def change_project_directory(%__MODULE__{} = project, project_directory) do + set_working_uri(project, project_directory) + end + + @spec change_environment_variables(t, map() | nil) :: + {:ok, t} | error_with_message() | restart_notification() + def change_environment_variables(%__MODULE__{} = project, environment_variables) do + set_env_vars(project, environment_variables) + end + + # private + + defp maybe_set_root_uri(%__MODULE__{} = project, nil), + do: %__MODULE__{project | root_uri: nil} + + defp maybe_set_root_uri(%__MODULE__{} = project, "file://" <> _ = root_uri) do + root_path = SourceFile.Path.absolute_from_uri(root_uri) + + with :ok <- File.cd(root_path), + {:ok, cwd} <- File.cwd() do + cwd_uri = SourceFile.Path.to_uri(cwd) + %__MODULE__{project | root_uri: cwd_uri} + else + _ -> + project + end + end + + # Project Path + defp set_working_uri(%__MODULE__{root_uri: root_uri} = old_project, project_directory) + when is_binary(root_uri) and project_directory != "" do + root_path = SourceFile.Path.absolute_from_uri(root_uri) + + normalized_project_dir = + if is_binary(project_directory) and project_directory != "" do + root_path + |> Path.join(project_directory) + |> Path.expand(root_path) + |> Path.absname() + else + root_path + end + + cond do + not File.dir?(normalized_project_dir) -> + {:error, "Project directory #{normalized_project_dir} does not exist"} + + not subdirectory?(root_path, normalized_project_dir) -> + message = + "Project directory '#{normalized_project_dir}' is not a subdirectory of '#{root_path}'" + + {:error, message} + + is_nil(old_project.working_uri) and subdirectory?(root_path, normalized_project_dir) -> + :ok = File.cd(normalized_project_dir) + + mix_exs_path = find_mix_exs_path(normalized_project_dir) + mix_project? = mix_exs_exists?(mix_exs_path) + + mix_exs_uri = + if mix_project? do + SourceFile.Path.to_uri(mix_exs_path) + else + nil + end + + working_uri = SourceFile.Path.to_uri(normalized_project_dir) + + new_project = %__MODULE__{ + old_project + | working_uri: working_uri, + mix_project?: mix_project?, + mix_exs_uri: mix_exs_uri + } + + {:ok, new_project} + + project_path(old_project) != normalized_project_dir -> + {:restart, :warning, "Project directory change detected. ElixirLS will restart"} + + true -> + {:ok, old_project} + end + end + + defp set_working_uri(%__MODULE__{} = old_project, _) do + {:ok, old_project} + end + + # Mix env + + defp set_mix_env(%__MODULE__{mix_env: old_env} = old_project, new_env) + when is_binary(new_env) and new_env != "" do + case {old_env, String.to_existing_atom(new_env)} do + {nil, nil} -> + Mix.env(:test) + {:ok, %__MODULE__{old_project | mix_env: :test}} + + {nil, new_env} -> + Mix.env(new_env) + {:ok, %__MODULE__{old_project | mix_env: new_env}} + + {same, same} -> + {:ok, old_project} + + _ -> + {:restart, :warning, "Mix env change detected. ElixirLS will restart."} + end + end + + defp set_mix_env(%__MODULE__{mix_env: nil} = project, _) do + Mix.env(:test) + + {:ok, %__MODULE__{project | mix_env: :test}} + end + + defp set_mix_env(%__MODULE__{} = project, _) do + {:ok, project} + end + + # Mix target + defp set_mix_target(%__MODULE__{} = old_project, new_target) + when is_binary(new_target) and new_target != "" do + case {old_project.mix_target, String.to_atom(new_target)} do + {nil, new_target} -> + Mix.target(new_target) + {:ok, %__MODULE__{old_project | mix_target: new_target}} + + {same, same} -> + {:ok, old_project} + + _ -> + {:restart, :warning, "Mix target change detected. ElixirLS will restart"} + end + end + + defp set_mix_target(%__MODULE__{} = old_project, _) do + {:ok, old_project} + end + + # Environment variables + + def set_env_vars(%__MODULE__{} = old_project, %{} = env_vars) do + case {old_project.env_variables, env_vars} do + {nil, vars} when map_size(vars) == 0 -> + {:ok, %__MODULE__{old_project | env_variables: vars}} + + {nil, new_vars} -> + System.put_env(new_vars) + {:ok, %__MODULE__{old_project | env_variables: new_vars}} + + {same, same} -> + {:ok, old_project} + + _ -> + {:restart, :warning, "Environment variables have changed. ElixirLS needs to restart"} + end + end + + def set_env_vars(%__MODULE__{} = old_project, _) do + {:ok, old_project} + end + + defp subdirectory?(parent, possible_child) do + parent_path = Path.expand(parent) + child_path = Path.expand(possible_child, parent) + + String.starts_with?(child_path, parent_path) + end + + defp find_mix_exs_path(project_directory) do + case System.get_env("MIX_EXS") do + nil -> + Path.join(project_directory, "mix.exs") + + mix_exs -> + mix_exs + end + end + + defp mix_exs_exists?(nil), do: false + + defp mix_exs_exists?(mix_exs_path) do + File.exists?(mix_exs_path) + end +end diff --git a/apps/language_server/lib/language_server/experimental/protocol/id.ex b/apps/language_server/lib/language_server/experimental/protocol/id.ex new file mode 100644 index 000000000..9562da8f2 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/protocol/id.ex @@ -0,0 +1,7 @@ +defmodule ElixirLS.LanguageServer.Experimental.Protocol.Id do + def next do + [:monotonic, :positive] + |> System.unique_integer() + |> to_string() + end +end diff --git a/apps/language_server/lib/language_server/experimental/protocol/notifications.ex b/apps/language_server/lib/language_server/experimental/protocol/notifications.ex index 487ed6da0..8a42fa7c4 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/notifications.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/notifications.ex @@ -2,48 +2,54 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Notifications do alias ElixirLS.LanguageServer.Experimental.Protocol.Proto alias ElixirLS.LanguageServer.Experimental.Protocol.Types + defmodule Initialized do + use Proto + defnotification "initialized", :shared + end + defmodule Cancel do use Proto - defnotification "$/cancelRequest", id: integer() + defnotification("$/cancelRequest", :shared, id: integer()) end defmodule DidOpen do use Proto - defnotification "textDocument/didOpen", text_document: Types.TextDocument + defnotification("textDocument/didOpen", :shared, text_document: Types.TextDocument) end defmodule DidClose do use Proto - defnotification "textDocument/didClose", text_document: Types.TextDocument.Identifier + defnotification("textDocument/didClose", :shared, text_document: Types.TextDocument.Identifier) end defmodule DidChange do use Proto - defnotification "textDocument/didChange", + defnotification("textDocument/didChange", :shared, text_document: Types.TextDocument.VersionedIdentifier, content_changes: list_of(Types.TextDocument.ContentChangeEvent) + ) end defmodule DidChangeConfiguration do use Proto - defnotification "workspace/didChangeConfiguration", settings: map_of(any()) + defnotification("workspace/didChangeConfiguration", :shared, settings: map_of(any())) end defmodule DidChangeWatchedFiles do use Proto - defnotification "workspace/didChangeWatchedFiles", changes: list_of(Types.FileEvent) + defnotification("workspace/didChangeWatchedFiles", :shared, changes: list_of(Types.FileEvent)) end defmodule DidSave do use Proto - defnotification "textDocument/didSave", text_document: Types.TextDocument.Identifier + defnotification("textDocument/didSave", :shared, text_document: Types.TextDocument.Identifier) end use Proto, decoders: :notifications diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto.ex b/apps/language_server/lib/language_server/experimental/protocol/proto.ex index 382d6e902..582779b68 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto.ex @@ -5,11 +5,11 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto do quote location: :keep do alias ElixirLS.LanguageServer.Experimental.Protocol.Proto alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.LspTypes - import ElixirLS.LanguageServer.Experimental.Protocol.Proto.TypeFunctions + import ElixirLS.LanguageServer.Experimental.Protocol.Proto.TypeFunctions import Proto.Enum, only: [defenum: 1] - import Proto.Notification, only: [defnotification: 2] - import Proto.Request, only: [defrequest: 2] + import Proto.Notification, only: [defnotification: 2, defnotification: 3] + import Proto.Request, only: [defrequest: 3] import Proto.Response, only: [defresponse: 1] import Proto.Type, only: [deftype: 1] end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex index df4939f79..48c3f8b07 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex @@ -1,6 +1,5 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert do alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions - alias ElixirLS.LanguageServer.Experimental.Protocol.Types alias ElixirLS.LanguageServer.Experimental.SourceFile def to_elixir(%{text_document: _} = request) do @@ -21,17 +20,17 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert do {:ok, request} end - defp fetch_source_file(%{text_document: %Types.TextDocument{} = text_document}) do - with {:ok, source_file} <- SourceFile.Store.fetch(text_document.uri) do - {:ok, source_file} - end + defp fetch_source_file(%{text_document: %{uri: uri}}) do + SourceFile.Store.fetch(uri) end defp fetch_source_file(%{source_file: %SourceFile{} = source_file}) do {:ok, source_file} end - defp fetch_source_file(_), do: :error + defp fetch_source_file(_) do + :error + end defp convert(%{range: range}, source_file) do with {:ok, ex_range} <- Conversions.to_elixir(range, source_file) do diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/decoders.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/decoders.ex index 94de139af..ef1c8f115 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/decoders.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/decoders.ex @@ -5,8 +5,11 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Decoders do notification_modules = CompileMetadata.notification_modules() notification_matchers = Enum.map(notification_modules, &build_notification_matcher_macro/1) notification_decoders = Enum.map(notification_modules, &build_notifications_decoder/1) + access_map = build_acces_map(notification_modules) quote do + alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert + defmacro notification(method) do quote do %{"method" => unquote(method), "jsonrpc" => "2.0"} @@ -37,6 +40,14 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Decoders do def __meta__(:notifications) do unquote(notification_modules) end + + def __meta__(:access) do + %{unquote_splicing(access_map)} + end + + def to_elixir(%{lsp: _} = request_or_notification) do + Convert.to_elixir(request_or_notification) + end end end @@ -44,12 +55,19 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Decoders do request_modules = CompileMetadata.request_modules() request_matchers = Enum.map(request_modules, &build_request_matcher_macro/1) request_decoders = Enum.map(request_modules, &build_request_decoder/1) + access_map = build_acces_map(request_modules) quote do + alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert + def __meta__(:requests) do unquote(request_modules) end + def __meta__(:access) do + %{unquote_splicing(access_map)} + end + defmacro request(id, method) do quote do %{"method" => unquote(method), "id" => unquote(id), "jsonrpc" => "2.0"} @@ -72,9 +90,19 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Decoders do def decode(method, _) do {:error, {:unknown_request, method}} end + + def to_elixir(%{lsp: _} = request_or_notification) do + Convert.to_elixir(request_or_notification) + end end end + defp build_acces_map(modules) do + Enum.map(modules, fn module -> + quote(do: {unquote(module.method()), unquote(module.__meta__(:access))}) + end) + end + defp build_notification_matcher_macro(notification_module) do macro_name = module_to_macro_name(notification_module) method_name = notification_module.__meta__(:method_name) diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex index f0459f5be..c0c02f940 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex @@ -60,7 +60,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Field do end def extract(module, _name, orig_value) - when is_atom(module) and module not in [:integer, :string] do + when is_atom(module) and module not in [:integer, :string, :boolean] do module.parse(orig_value) end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/lsp_types.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/lsp_types.ex index bb490e38d..388f75d2f 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/lsp_types.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/lsp_types.ex @@ -22,4 +22,20 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.LspTypes do use Proto deftype code: ErrorCodes, message: string(), data: optional(any()) end + + defmodule ClientInfo do + use Proto + deftype name: string(), version: optional(string()) + end + + defmodule TraceValue do + use Proto + defenum off: "off", messages: "messages", verbose: "verbose" + end + + defmodule Registration do + use Proto + + deftype id: string(), method: string(), register_options: optional(any()) + end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/macros/inspect.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/inspect.ex new file mode 100644 index 000000000..9a2896726 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/inspect.ex @@ -0,0 +1,36 @@ +defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Inspect do + def build(dest_module) do + trimmed_name = trim_module_name(dest_module) + + quote location: :keep do + defimpl Inspect, for: unquote(dest_module) do + import Inspect.Algebra + + def inspect(proto_type, opts) do + proto_map = Map.from_struct(proto_type) + concat(["%#{unquote(trimmed_name)}", to_doc(proto_map, opts), ""]) + end + end + end + end + + def trim_module_name(long_name) do + {sub_modules, _} = + long_name + |> Module.split() + |> Enum.reduce({[], false}, fn + "Protocol", _ -> + {["Protocol"], true} + + _ignored_module, {_, false} = state -> + state + + submodule, {mod_list, true} -> + {[submodule | mod_list], true} + end) + + sub_modules + |> Enum.reverse() + |> Enum.join(".") + end +end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/macros/message.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/message.ex index 5b33079a0..9b2fa8e96 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/macros/message.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/message.ex @@ -9,7 +9,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Message do alias ElixirLS.LanguageServer.Experimental.SourceFile - def build(meta_type, method, types, param_names, opts \\ []) do + def build(meta_type, method, access, types, param_names, opts \\ []) do parse_fn = if Keyword.get(opts, :include_parse?, true) do Parse.build(types) @@ -22,6 +22,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Message do unquote(parse_fn) unquote(Meta.build(types)) + def method do + unquote(method) + end + def __meta__(:method_name) do unquote(method) end @@ -33,6 +37,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Message do def __meta__(:param_names) do unquote(param_names) end + + def __meta__(:access) do + unquote(access) + end end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex index eb82449a4..3d9653bec 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex @@ -2,7 +2,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Notification do alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.CompileMetadata alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Message - defmacro defnotification(method, types) do + defmacro defnotification(method, access, types \\ []) do CompileMetadata.add_notification_module(__CALLER__.module) jsonrpc_types = [ @@ -16,13 +16,13 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Notification do quote location: :keep do defmodule LSP do - unquote(Message.build({:notification, :lsp}, method, lsp_types, param_names)) + unquote(Message.build({:notification, :lsp}, method, access, lsp_types, param_names)) end alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert unquote( - Message.build({:notification, :elixir}, method, elixir_types, param_names, + Message.build({:notification, :elixir}, method, access, elixir_types, param_names, include_parse?: false ) ) diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex index a1101af5a..13292b2b5 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex @@ -3,15 +3,15 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Request do alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Message alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.TypeFunctions - import TypeFunctions, only: [optional: 1, integer: 0, literal: 1] + import TypeFunctions, only: [optional: 1, literal: 1] - defmacro defrequest(method, types) do + defmacro defrequest(method, access, types) do CompileMetadata.add_request_module(__CALLER__.module) # id is optional so we can resuse the parse function. If it's required, # it will go in the pattern match for the params, which won't work. jsonrpc_types = [ - id: quote(do: optional(integer())), + id: quote(do: optional(one_of([string(), integer()]))), jsonrpc: quote(do: literal("2.0")), method: quote(do: literal(unquote(method))) ] @@ -19,17 +19,18 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Request do lsp_types = Keyword.merge(jsonrpc_types, types) elixir_types = Message.generate_elixir_types(__CALLER__.module, lsp_types) param_names = Keyword.keys(types) + lsp_module_name = Module.concat(__CALLER__.module, LSP) quote location: :keep do defmodule LSP do - unquote(Message.build({:request, :lsp}, method, lsp_types, param_names)) + unquote(Message.build({:request, :lsp}, method, access, lsp_types, param_names)) end alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert alias ElixirLS.LanguageServer.Experimental.Protocol.Types unquote( - Message.build({:request, :elixir}, method, elixir_types, param_names, + Message.build({:request, :elixir}, method, access, elixir_types, param_names, include_parse?: false ) ) @@ -44,6 +45,24 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Request do def to_elixir(%__MODULE__{} = request) do Convert.to_elixir(request) end + + defimpl JasonVendored.Encoder, for: unquote(__CALLER__.module) do + def encode(request, opts) do + JasonVendored.Encoder.encode(request.lsp, opts) + end + end + + defimpl JasonVendored.Encoder, for: unquote(lsp_module_name) do + def encode(request, opts) do + %{ + id: request.id, + jsonrpc: "2.0", + method: unquote(method), + params: Map.take(request, unquote(param_names)) + } + |> JasonVendored.Encode.map(opts) + end + end end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/requests.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/requests.ex deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/type.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/type.ex index 64665a702..d4ad1ef06 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/type.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/type.ex @@ -3,6 +3,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Type do alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.{ Access, + Inspect, Json, Match, Meta, @@ -17,6 +18,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Type do quote location: :keep do unquote(Json.build(caller_module)) + unquote(Inspect.build(caller_module)) unquote(Access.build()) unquote(Struct.build(types)) unquote(Typespec.build(types)) diff --git a/apps/language_server/lib/language_server/experimental/protocol/requests.ex b/apps/language_server/lib/language_server/experimental/protocol/requests.ex index f13f85dd3..b2ba4264a 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/requests.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/requests.ex @@ -1,13 +1,47 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Requests do + alias ElixirLS.LanguageServer.Experimental.Protocol.LspTypes alias ElixirLS.LanguageServer.Experimental.Protocol.Proto alias ElixirLS.LanguageServer.Experimental.Protocol.Types + defmodule Initialize do + use Proto + + defrequest "initialize", :shared, + process_id: optional(integer()), + client_info: optional(LspTypes.ClientInfo), + locale: optional(string()), + root_path: optional(string()), + root_uri: string(), + initialization_options: optional(map_of(any())), + trace: optional(string()), + workspace_folders: optional(Types.WorkspaceFolder), + capabilities: optional(map_of(any())) + end + defmodule FindReferences do use Proto - defrequest "textDocument/references", + defrequest("textDocument/references", :exclusive, text_document: Types.TextDocument.Identifier, position: Types.Position + ) + end + + defmodule Formatting do + use Proto + + defrequest("textDocument/formatting", :exclusive, + text_document: Types.TextDocument.Identifier, + options: Types.FormattingOptions + ) + end + + defmodule RegisterCapability do + use Proto + + defrequest("client/registerCapability", :shared, + registrations: optional(list_of(LspTypes.Registration)) + ) end use Proto, decoders: :requests diff --git a/apps/language_server/lib/language_server/experimental/protocol/responses.ex b/apps/language_server/lib/language_server/experimental/protocol/responses.ex index f44c4bddd..96ddc5038 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/responses.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/responses.ex @@ -2,9 +2,15 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Responses do alias ElixirLS.LanguageServer.Experimental.Protocol.Proto alias ElixirLS.LanguageServer.Experimental.Protocol.Types - defmodule FindReferencesResponse do + defmodule FindReferences do use Proto defresponse optional(list_of(Types.Location)) end + + defmodule Formatting do + use Proto + + defresponse optional(list_of(Types.TextEdit)) + end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/types.ex b/apps/language_server/lib/language_server/experimental/protocol/types.ex index afa2862d8..5966bbf29 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/types.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/types.ex @@ -79,7 +79,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Types do data: optional(any()) end - defmodule TypeFormattingOptions do + defmodule TextEdit do + use Proto + deftype range: Range, new_text: string() + end + + defmodule FormattingOptions do use Proto deftype tab_size: integer(), @@ -87,7 +92,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Types do trim_trailing_whitespace: optional(boolean()), insert_final_newline: optional(boolean()), trim_final_newlines: optional(boolean()), - "*rest": one_of([string(), boolean(), integer()]) + ..: map_of(one_of([string(), boolean(), integer()]), as: :opts) end defmodule FileChangeType do @@ -139,7 +144,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Types do use Proto deftype document_changes: optional(boolean()), - resource_operations: optional(boolean()) + resource_operations: optional(list_of(ResourceOperationKind)) end defmodule DidChangeConfiguration.ClientCapabilities do @@ -247,7 +252,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Types do deftype refresh_support: optional(boolean()) end - defmodule CodeLensWorkspac.ClientCapabilities do + defmodule CodeLensWorkspace.ClientCapabilities do use Proto deftype refresh_support: optional(boolean()) @@ -418,12 +423,17 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Types do deftype workspace: WorkspaceCapabilities, text_document: TextDocument.Capabilities, - window: WindowCapabilities, - general: GeneralCapabilities + # window: WindowCapabilities, + general: optional(GeneralCapabilities) end defmodule InitializeParams do use Proto deftype root_uri: uri(), capabilities: map_of(any()) end + + defmodule WorkspaceFolder do + use Proto + deftype uri: uri(), name: string() + end end diff --git a/apps/language_server/lib/language_server/experimental/provider/env.ex b/apps/language_server/lib/language_server/experimental/provider/env.ex new file mode 100644 index 000000000..adcf513b1 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/env.ex @@ -0,0 +1,22 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.Env do + alias ElixirLS.LanguageServer.Experimental.Project + alias ElixirLS.LanguageServer.Experimental.Server.Configuration + alias ElixirLS.LanguageServer.SourceFile + + defstruct [:root_uri, :root_path, :project_uri, :project_path] + + @type t :: %__MODULE__{} + + def new do + %__MODULE__{} + end + + def from_configuration(%Configuration{} = config) do + %__MODULE__{ + root_uri: config.project.root_uri, + root_path: Project.root_path(config.project), + project_uri: config.project.root_uri, + project_path: Project.project_path(config.project) + } + end +end diff --git a/apps/language_server/lib/language_server/experimental/provider/handlers/find_references.ex b/apps/language_server/lib/language_server/experimental/provider/handlers/find_references.ex new file mode 100644 index 000000000..2a860d8fd --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/handlers/find_references.ex @@ -0,0 +1,58 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.FindReferences do + alias ElixirLS.LanguageServer.Build + alias ElixirLS.LanguageServer.Tracer + + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.FindReferences + alias ElixirLS.LanguageServer.Experimental.Protocol.Responses + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Location + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions + + require Logger + + def handle(%FindReferences{} = request, _) do + source_file = request.source_file + pos = request.position + trace = Tracer.get_trace() + # elixir_ls uses 1 based columns, so add 1 here. + character = pos.character + 1 + + Build.with_build_lock(fn -> + references = + source_file + |> SourceFile.to_string() + |> ElixirSense.references(pos.line, character, trace) + |> Enum.reduce([], fn reference, acc -> + case build_reference(reference, source_file) do + {:ok, location} -> + [location | acc] + + _ -> + acc + end + end) + |> Enum.reverse() + + response = Responses.FindReferences.new(request.id, references) + Logger.info("found #{length(references)} refs") + {:reply, response} + end) + end + + defp build_reference(%{range: _, uri: _} = elixir_sense_reference, current_source_file) do + with {:ok, source_file} <- get_source_file(elixir_sense_reference, current_source_file), + {:ok, elixir_range} <- Conversions.to_elixir(elixir_sense_reference, source_file), + {:ok, ls_range} <- Conversions.to_lsp(elixir_range, source_file) do + uri = Conversions.ensure_uri(source_file.uri) + {:ok, Location.new(uri: uri, range: ls_range)} + end + end + + defp get_source_file(%{uri: nil}, current_source_file) do + {:ok, current_source_file} + end + + defp get_source_file(%{uri: uri}, _) do + SourceFile.Store.open_temporary(uri) + end +end diff --git a/apps/language_server/lib/language_server/experimental/provider/handlers/formatting.ex b/apps/language_server/lib/language_server/experimental/provider/handlers/formatting.ex new file mode 100644 index 000000000..0bef9b664 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/handlers/formatting.ex @@ -0,0 +1,22 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.Formatting do + alias ElixirLS.LanguageServer.Experimental.Provider.Env + alias ElixirLS.LanguageServer.Experimental.Format + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests + alias ElixirLS.LanguageServer.Experimental.Protocol.Responses + require Logger + + def handle(%Requests.Formatting{} = request, %Env{} = env) do + document = request.source_file + + with {:ok, text_edits} <- Format.text_edits(document, env.project_path) do + response = Responses.Formatting.new(request.id, text_edits) + {:reply, response} + else + {:error, reason} -> + Logger.error("Formatter failed #{inspect(reason)}") + + {:reply, + Responses.Formatting.error(request.id, :request_failed, Exception.message(reason))} + end + end +end diff --git a/apps/language_server/lib/language_server/experimental/provider/queue.ex b/apps/language_server/lib/language_server/experimental/provider/queue.ex new file mode 100644 index 000000000..e514d142f --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/queue.ex @@ -0,0 +1,228 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.Queue do + defmodule State do + alias ElixirLS.LanguageServer.Experimental.Provider.Env + alias ElixirLS.LanguageServer.Experimental + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests + alias ElixirLS.LanguageServer.Experimental.Provider.Handlers + alias ElixirLS.LanguageServer.Experimental.Provider.Queue + alias ElixirLS.Utils.WireProtocol + require Logger + + defstruct tasks_by_id: %{}, pids_to_ids: %{} + + @type t :: %__MODULE__{} + + @requests_to_handler %{ + Requests.FindReferences => Handlers.FindReferences, + Requests.Formatting => Handlers.Formatting + } + + def new do + %__MODULE__{} + end + + defp handler_for(%request_module{}) do + case Map.fetch(@requests_to_handler, request_module) do + {:ok, _} = success -> + success + + :error -> + {:error, {:unhandled, request_module}} + end + end + + @spec add(t, Requests.request(), Env.t()) :: {:ok, t} | :error + def add(%__MODULE__{} = state, request, env) do + with {:ok, handler_module} <- handler_for(request), + {:ok, req} <- request.__struct__.to_elixir(request) do + task = %Task{} = as_task(request, fn -> handler_module.handle(req, env) end) + + new_state = %__MODULE__{ + state + | tasks_by_id: Map.put(state.tasks_by_id, request.id, task), + pids_to_ids: Map.put(state.pids_to_ids, task.pid, request.id) + } + + {:ok, new_state} + else + {:error, {:unhandled, _}} -> + Logger.info("unhandled request #{request.method}") + :error + + _ -> + :error + end + end + + @spec cancel(t, pos_integer()) :: t + def cancel(%__MODULE__{} = state, request_id) do + with {:ok, %Task{} = task} <- Map.fetch(state.tasks_by_id, request_id), + true <- Process.exit(task.pid, :kill) do + %State{ + state + | tasks_by_id: Map.delete(state.tasks_by_id, request_id), + pids_to_ids: Map.delete(state.pids_to_ids, task.pid) + } + else + _ -> + state + end + end + + def size(%__MODULE__{} = state) do + map_size(state.tasks_by_id) + end + + def task_finished(%__MODULE__{} = state, pid, reason) do + case Map.pop(state.pids_to_ids, pid) do + {nil, _} -> + Logger.warn("Got an exit for pid #{inspect(pid)}, but it wasn't in the queue") + state + + {request_id, new_pids_to_ids} -> + maybe_log_task(reason, request_id) + + %__MODULE__{ + state + | pids_to_ids: new_pids_to_ids, + tasks_by_id: Map.delete(state.tasks_by_id, request_id) + } + end + end + + def running?(%__MODULE__{} = state, request_id) do + Map.has_key?(state.tasks_by_id, request_id) + end + + defp maybe_log_task(:normal, _), + do: :ok + + defp maybe_log_task(reason, %{id: request_id} = _request), + do: maybe_log_task(reason, request_id) + + defp maybe_log_task(reason, request_id), + do: Logger.warn("Request id #{request_id} failed with reason #{inspect(reason)}") + + defp as_task(%{id: _} = request, func) do + handler = fn -> + try do + case func.() do + :noreply -> + {:request_complete, request} + + {:reply, reply} -> + WireProtocol.send(reply) + {:request_complete, request} + + {:reply_and_alert, reply} -> + WireProtocol.send(reply) + Experimental.Server.response_complete(request, reply) + {:request_complete, request} + end + rescue + e -> + exception_string = Exception.format(:error, e, __STACKTRACE__) + Logger.error(exception_string) + + WireProtocol.send(%{ + id: request.id, + error: exception_string + }) + + {:request_complete, request} + end + end + + Queue.Supervisor.run_in_task(handler) + end + end + + alias ElixirLS.LanguageServer.Experimental.Provider.Env + alias ElixirLS.LanguageServer.Experimental.Server.Configuration + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests + + use GenServer + + # public interface + @spec add(Requests.request(), Configuration.t() | Env.t()) :: :ok + def add(request, %Configuration{} = config) do + env = Env.from_configuration(config) + add(request, env) + end + + def add(request, %Env{} = env) do + GenServer.call(__MODULE__, {:add, request, env}) + end + + @spec size() :: non_neg_integer() + def size do + GenServer.call(__MODULE__, :size) + end + + def cancel(%{id: request_id}) do + cancel(request_id) + end + + def cancel(request_id) when is_binary(request_id) do + GenServer.call(__MODULE__, {:cancel, request_id}) + end + + def running?(%{id: request_id}) do + running?(request_id) + end + + def running?(request_id) when is_binary(request_id) do + GenServer.call(__MODULE__, {:running?, request_id}) + end + + # genserver callbacks + + def child_spec do + __MODULE__ + end + + def start_link(_) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, State.new()} + end + + def handle_call({:add, request, env}, _from, %State{} = state) do + {reply, new_state} = + case State.add(state, request, env) do + {:ok, new_state} -> {:ok, new_state} + error -> {error, state} + end + + {:reply, reply, new_state} + end + + def handle_call({:cancel, request_id}, _from, %State{} = state) do + new_state = State.cancel(state, request_id) + {:reply, :ok, new_state} + end + + def handle_call({:running?, request_id}, _from, %State{} = state) do + {:reply, State.running?(state, request_id), state} + end + + def handle_call(:size, _from, %State{} = state) do + {:reply, State.size(state), state} + end + + def handle_info({:DOWN, _ref, :process, pid, reason}, state) do + new_state = State.task_finished(state, pid, reason) + + {:noreply, new_state} + end + + def handle_info({ref, {:request_complete, _response}}, %State{} = state) + when is_reference(ref) do + # This head handles the replies from the tasks, which we don't really care about. + {:noreply, state} + end + + # private +end diff --git a/apps/language_server/lib/language_server/experimental/provider/supervisor.ex b/apps/language_server/lib/language_server/experimental/provider/supervisor.ex new file mode 100644 index 000000000..d10706d64 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/supervisor.ex @@ -0,0 +1,20 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.Queue.Supervisor do + def name do + __MODULE__ + end + + def child_spec do + {Task.Supervisor, name: name()} + end + + def run_in_task(provider_fn) do + name() + |> Task.Supervisor.async(provider_fn) + |> unlink() + end + + defp unlink(%Task{} = task) do + Process.unlink(task.pid) + task + end +end diff --git a/apps/language_server/lib/language_server/experimental/server.ex b/apps/language_server/lib/language_server/experimental/server.ex index ec22fc15b..cef57efb1 100644 --- a/apps/language_server/lib/language_server/experimental/server.ex +++ b/apps/language_server/lib/language_server/experimental/server.ex @@ -1,6 +1,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Server do + alias ElixirLS.LanguageServer.Experimental.Provider alias ElixirLS.LanguageServer.Experimental.Protocol.Notifications alias ElixirLS.LanguageServer.Experimental.Protocol.Requests + alias ElixirLS.LanguageServer.Experimental.Protocol.Responses alias ElixirLS.LanguageServer.Experimental.Server.State import Logger @@ -9,6 +11,11 @@ defmodule ElixirLS.LanguageServer.Experimental.Server do use GenServer + @spec response_complete(Requests.request(), Responses.response()) :: :ok + def response_complete(request, response) do + GenServer.call(__MODULE__, {:response_complete, request, response}) + end + def start_link(_) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @@ -17,11 +24,14 @@ defmodule ElixirLS.LanguageServer.Experimental.Server do {:ok, State.new()} end - def handle_cast({:receive_packet, request(method, _id) = request}, %State{} = state) do + def handle_call({:response_complete, _request, _response}, _from, %State{} = state) do + {:reply, :ok, state} + end + + def handle_cast({:receive_packet, request(_id, method) = request}, %State{} = state) do new_state = with {:ok, request} <- Requests.decode(method, request), {:ok, new_state} <- handle_request(request, %State{} = state) do - info("Decoded #{request.__struct__}") new_state else {:error, {:unknown_request, _}} -> @@ -58,7 +68,36 @@ defmodule ElixirLS.LanguageServer.Experimental.Server do {:noreply, state} end - def handle_request(_, %State{} = state) do + def handle_info(:default_config, %State{configuration: nil} = state) do + Logger.warn( + "Did not receive workspace/didChangeConfiguration notification after 5 seconds. " <> + "Using default settings." + ) + + {:ok, config} = State.default_configuration(state) + {:noreply, %State{state | configuration: config}} + end + + def handle_info(:default_config, %State{} = state) do + {:noreply, state} + end + + def handle_request(%Requests.Initialize{} = initialize, %State{} = state) do + Logger.info("handling initialize") + Process.send_after(self(), :default_config, :timer.seconds(5)) + + case State.initialize(state, initialize) do + {:ok, _state} = success -> + success + + error -> + {error, state} + end + end + + def handle_request(request, %State{} = state) do + Provider.Queue.add(request, state.configuration) + {:ok, %State{} = state} end @@ -72,14 +111,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Server do end end - defp apply_to_state(%State{} = state, %protocol_module{} = protocol_action) do - {elapsed_us, result} = :timer.tc(fn -> State.apply(state, protocol_action) end) - elapsed_ms = Float.round(elapsed_us / 1000, 2) - method_name = protocol_module.__meta__(:method_name) - - info("#{method_name} took #{elapsed_ms}ms") - - case result do + defp apply_to_state(%State{} = state, %{} = request_or_notification) do + case State.apply(state, request_or_notification) do {:ok, new_state} -> {:ok, new_state} :ok -> {:ok, state} error -> {error, state} diff --git a/apps/language_server/lib/language_server/experimental/server/configuration.ex b/apps/language_server/lib/language_server/experimental/server/configuration.ex new file mode 100644 index 000000000..384a7ee35 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/server/configuration.ex @@ -0,0 +1,137 @@ +defmodule ElixirLS.LanguageServer.Experimental.Server.Configuration do + alias ElixirLS.LanguageServer.Dialyzer + alias ElixirLS.LanguageServer.Experimental.LanguageServer + alias ElixirLS.LanguageServer.Experimental.Project + alias ElixirLS.LanguageServer.Experimental.Protocol.Id + alias ElixirLS.LanguageServer.Experimental.Protocol.Notifications.DidChangeConfiguration + alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.LspTypes.Registration + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.RegisterCapability + alias ElixirLS.LanguageServer.Experimental.Server.Configuration.Support + alias ElixirLS.LanguageServer.SourceFile + alias ElixirLS.Utils.MixfileHelpers + + defstruct project: nil, + support: nil, + additional_watched_extensions: nil, + dialyzer_enabled?: false + + @type t :: %__MODULE__{} + + @spec new(LanguageServer.uri(), map()) :: t + def new(root_uri, client_capabilities) do + support = Support.new(client_capabilities) + project = Project.new(root_uri) + %__MODULE__{support: support, project: project} + end + + @spec default(t) :: + {:ok, t} + | {:ok, t, Requests.RegisterCapabilities.t()} + | {:restart, Logger.level(), String.t()} + def default(%__MODULE__{} = config) do + apply_config_change(config, default_config()) + end + + @spec on_change(t, DidChangeConfiguration.t()) :: + {:ok, t} + | {:ok, t, Requests.RegisterCapability.t()} + | {:restart, Logger.level(), String.t()} + def on_change(%__MODULE__{} = old_config, :defaults) do + apply_config_change(old_config, default_config()) + end + + def on_change(%__MODULE__{} = old_config, %DidChangeConfiguration{} = change) do + apply_config_change(old_config, change.lsp.settings) + end + + defp default_config do + %{} + end + + defp apply_config_change(%__MODULE__{} = old_config, %{} = settings) do + with {:ok, new_config} <- maybe_set_mix_env(old_config, settings), + {:ok, new_config} <- maybe_set_env_vars(new_config, settings), + {:ok, new_config} <- maybe_set_mix_target(new_config, settings), + {:ok, new_config} <- maybe_set_project_directory(new_config, settings), + {:ok, new_config} <- maybe_enable_dialyzer(new_config, settings) do + maybe_add_watched_extensions(new_config, settings) + end + end + + defp maybe_set_mix_env(%__MODULE__{} = old_config, settings) do + new_env = Map.get(settings, "mixEnv") + + with {:ok, new_project} <- Project.change_mix_env(old_config.project, new_env) do + {:ok, %__MODULE__{old_config | project: new_project}} + end + end + + defp maybe_set_env_vars(%__MODULE__{} = old_config, settings) do + env_vars = Map.get(settings, "envVariables") + + with {:ok, new_project} <- Project.set_env_vars(old_config.project, env_vars) do + {:ok, %__MODULE__{old_config | project: new_project}} + end + end + + defp maybe_set_mix_target(%__MODULE__{} = old_config, settings) do + mix_target = Map.get(settings, "mixTarget") + + with {:ok, new_project} <- Project.change_mix_target(old_config.project, mix_target) do + {:ok, %__MODULE__{old_config | project: new_project}} + end + end + + defp maybe_set_project_directory(%__MODULE__{} = old_config, settings) do + project_dir = Map.get(settings, "projectDir") + + with {:ok, new_project} <- Project.change_project_directory(old_config.project, project_dir) do + {:ok, %__MODULE__{old_config | project: new_project}} + end + end + + defp maybe_enable_dialyzer(%__MODULE__{} = old_config, settings) do + enabled? = + case Dialyzer.check_support() do + :ok -> + Map.get(settings, "dialyzerEnabled", true) + + _ -> + false + end + + {:ok, %__MODULE__{old_config | dialyzer_enabled?: enabled?}} + end + + defp maybe_add_watched_extensions(%__MODULE__{} = old_config, %{ + "additionalWatchedExtensions" => [] + }) do + {:ok, old_config} + end + + defp maybe_add_watched_extensions(%__MODULE__{} = old_config, %{ + "additionalWatchedExtensions" => extensions + }) + when is_list(extensions) do + register_id = Id.next() + request_id = Id.next() + + watchers = Enum.map(extensions, fn ext -> %{"globPattern" => "**/*#{ext}"} end) + + registration = + Registration.new( + id: request_id, + method: "workspace/didChangeWatchedFiles", + register_options: %{"watchers" => watchers} + ) + + request = RegisterCapability.new(id: register_id, registrations: [registration]) + + {:ok, old_config, request} + end + + defp maybe_add_watched_extensions(%__MODULE__{} = old_config, _) do + {:ok, old_config} + end +end diff --git a/apps/language_server/lib/language_server/experimental/server/configuration/support.ex b/apps/language_server/lib/language_server/experimental/server/configuration/support.ex new file mode 100644 index 000000000..520ebb433 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/server/configuration/support.ex @@ -0,0 +1,49 @@ +defmodule ElixirLS.LanguageServer.Experimental.Server.Configuration.Support do + defstruct code_action_dynamic_registration?: false, + hierarchical_document_symbols?: false, + snippet?: false, + deprecated?: false, + tags?: false, + signature_help?: false + + def new(client_capabilities) do + dynamic_registration? = + fetch_bool(client_capabilities, ~w(textDocument codeAction dynamicRegistration)) + + hierarchical_symbols? = + fetch_bool( + client_capabilities, + ~w(textDocument documentSymbol hierarchicalDocumentSymbolSupport) + ) + + snippet? = + fetch_bool(client_capabilities, ~w(textDocument completion completionItem snippetSupport)) + + deprecated? = + fetch_bool( + client_capabilities, + ~w(textDocument completion completionItem deprecatedSupport) + ) + + tags? = fetch_bool(client_capabilities, ~w(textDocument completion completionItem tagSupport)) + + signature_help? = fetch_bool(client_capabilities, ~w(textDocument signatureHelp)) + + %__MODULE__{ + code_action_dynamic_registration?: dynamic_registration?, + hierarchical_document_symbols?: hierarchical_symbols?, + snippet?: snippet?, + deprecated?: deprecated?, + tags?: tags?, + signature_help?: signature_help? + } + end + + def fetch_bool(client_capabilities, path) do + case get_in(client_capabilities, path) do + nil -> false + false -> false + _ -> true + end + end +end diff --git a/apps/language_server/lib/language_server/experimental/server/state.ex b/apps/language_server/lib/language_server/experimental/server/state.ex index 3b9902840..970f3cb93 100644 --- a/apps/language_server/lib/language_server/experimental/server/state.ex +++ b/apps/language_server/lib/language_server/experimental/server/state.ex @@ -1,22 +1,64 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.State do + alias ElixirLS.Utils.WireProtocol + alias ElixirLS.LanguageServer.Experimental.Protocol.Notifications.{ DidChange, + DidChangeConfiguration, DidClose, - DidSave, - DidOpen + DidOpen, + DidSave } + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.Initialize alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextDocument + alias ElixirLS.LanguageServer.Experimental.Server.Configuration alias ElixirLS.LanguageServer.Experimental.SourceFile import Logger - defstruct [] + defstruct configuration: nil, initialized?: false def new do %__MODULE__{} end + def initialize(%__MODULE__{initialized?: false} = state, %Initialize{ + lsp: %Initialize.LSP{} = event + }) do + config = Configuration.new(event.root_uri, event.capabilities) + new_state = %__MODULE__{state | configuration: config, initialized?: true} + {:ok, new_state} + end + + def initialize(%__MODULE__{initialized?: true}, %Initialize{}) do + {:error, :already_initialized} + end + + def default_configuration(%__MODULE__{configuration: config} = state) do + with {:ok, config} <- Configuration.default(config) do + {:ok, %__MODULE__{state | configuration: config}} + end + end + + def apply(%__MODULE__{initialized?: false}, request) do + Logger.error("Received #{request.method} before server was initialized") + {:error, :not_initialized} + end + + def apply(%__MODULE__{} = state, %DidChangeConfiguration{} = event) do + case Configuration.on_change(state.configuration, event) do + {:ok, config} -> + {:ok, %__MODULE__{state | configuration: config}} + + {:ok, config, response} -> + WireProtocol.send(response) + {:ok, %__MODULE__{state | configuration: config}} + + error -> + error + end + end + def apply(%__MODULE__{} = state, %DidChange{lsp: event}) do uri = event.text_document.uri version = event.text_document.version diff --git a/apps/language_server/lib/language_server/experimental/source_file.ex b/apps/language_server/lib/language_server/experimental/source_file.ex index e427c5991..2249cac32 100644 --- a/apps/language_server/lib/language_server/experimental/source_file.ex +++ b/apps/language_server/lib/language_server/experimental/source_file.ex @@ -24,6 +24,8 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do # public @spec new(URI.t(), String.t(), pos_integer()) :: t def new(uri, text, version) do + uri = Conversions.ensure_uri(uri) + %__MODULE__{ uri: uri, version: version, @@ -44,11 +46,17 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do @spec fetch_text_at(t, version()) :: {:ok, String.t()} | :error def fetch_text_at(%__MODULE{} = source, line_number) do - with {:ok, line(text: text)} <- Document.fetch_line(source.document, line_number) do - {:ok, text} - else - _ -> - :error + case fetch_line_at(source, line_number) do + {:ok, line(text: text)} -> {:ok, text} + _ -> :error + end + end + + @spec fetch_line_at(t, version()) :: {:ok, Line.t()} | :error + def fetch_line_at(%__MODULE__{} = source, line_number) do + case Document.fetch_line(source.document, line_number) do + {:ok, line} -> {:ok, line} + _ -> :error end end @@ -221,15 +229,16 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do end end - defp utf8_prefix(line(text: text), start_index) do - length = max(0, start_index) + defp utf8_prefix(line(text: text), start_code_unit) do + length = max(0, start_code_unit) binary_part(text, 0, length) end - defp utf8_suffix(line(text: text), start_index) do + defp utf8_suffix(line(text: text), start_code_unit) do byte_count = byte_size(text) - start_index = min(start_index, byte_count) + start_index = min(start_code_unit, byte_count) length = byte_count - start_index + binary_part(text, start_index, length) end diff --git a/apps/language_server/lib/language_server/experimental/source_file/conversions.ex b/apps/language_server/lib/language_server/experimental/source_file/conversions.ex index f0567c38d..9d4a91b51 100644 --- a/apps/language_server/lib/language_server/experimental/source_file/conversions.ex +++ b/apps/language_server/lib/language_server/experimental/source_file/conversions.ex @@ -7,6 +7,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do the line contains non-ascii characters. If it's a pure ascii line, then the positions are the same in both utf-8 and utf-16, since they reference characters and not bytes. """ + alias ElixirLS.LanguageServer.Experimental.CodeUnit alias ElixirLS.LanguageServer.Experimental.SourceFile alias ElixirLS.LanguageServer.Experimental.SourceFile.Line alias ElixirLS.LanguageServer.Experimental.SourceFile.Document @@ -21,6 +22,16 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do @elixir_ls_index_base 1 + def ensure_uri("file://" <> _ = uri), do: uri + + def ensure_uri(path), + do: ElixirLS.LanguageServer.SourceFile.Path.to_uri(path) + + def ensure_path("file://" <> _ = uri), + do: ElixirLS.LanguageServer.SourceFile.Path.from_uri(uri) + + def ensure_path(path), do: path + def to_elixir( %LSRange{} = ls_range, %SourceFile{} = source @@ -86,6 +97,20 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do end end + def to_elixir(%{range: %{start: start_pos, end: end_pos}}, _source_file) do + # this is actually an elixir sense range... note that it's a bare map with + # column keys rather than character keys. + %{line: start_line, column: start_col} = start_pos + %{line: end_line, column: end_col} = end_pos + + range = %ElixirRange{ + start: ElixirPosition.new(start_line, start_col - 1), + end: ElixirPosition.new(end_line, end_col - 1) + } + + {:ok, range} + end + def to_lsp(%ElixirRange{} = ex_range, %SourceFile{} = source) do with {:ok, start_pos} <- to_lsp(ex_range.start, source.document), {:ok, end_pos} <- to_lsp(ex_range.end, source.document) do @@ -117,53 +142,24 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do end def to_lsp(%LSPosition{} = position, _) do - position - end - - defp lsp_character_to_elixir(utf16_line, lsp_character) do - # In LSP, the word "character" is a misnomer. What's being counted is a code unit. - # in utf16, a code unit is two bytes long, while in utf8 it is one byte long. - # This function converts from utf16 code units to utf8 code units. The code units - # can then be used to do a simple byte-level operation on elixir binaries. - # For ascii text, the code unit will mirror the number of bytes, but if there's any - # unicode characters, it will vary from the byte count. - byte_size = byte_size(utf16_line) - - # if character index is over the length of the string assume we pad it with spaces (1 byte in utf8) - utf16_line - |> binary_part(0, min(lsp_character * 2, byte_size)) - |> to_utf8() - |> byte_size() + {:ok, position} end - def elixir_character_to_lsp(utf8_line, elixir_character) do - case utf8_line |> binary_part(0, elixir_character) |> to_utf16() do - {:ok, utf16_line} -> - character = - utf16_line - |> byte_size() - |> div(2) + # Private - {:ok, character} + defp extract_lsp_character(%ElixirPosition{} = position, line(ascii?: true)) do + {:ok, position.character} + end - error -> - error - end + defp extract_lsp_character(%ElixirPosition{} = position, line(text: utf8_text)) do + {:ok, CodeUnit.utf16_offset(utf8_text, position.character)} end - defp to_utf16(b) do - case :unicode.characters_to_binary(b, :utf8, :utf16) do - b when is_binary(b) -> {:ok, b} - {:error, _, _} = err -> err - {:incomplete, _, _} -> {:error, :incomplete} - end + defp extract_elixir_character(%LSPosition{} = position, line(ascii?: true)) do + {:ok, position.character} end - defp to_utf8(b) do - case :unicode.characters_to_binary(b, :utf16, :utf8) do - b when is_binary(b) -> b - {:error, _, _} = err -> err - {:incomplete, _, _} -> {:error, :incomplete} - end + defp extract_elixir_character(%LSPosition{} = position, line(text: utf8_text)) do + {:ok, CodeUnit.utf8_offset(utf8_text, position.character)} end end diff --git a/apps/language_server/lib/language_server/experimental/source_file/store.ex b/apps/language_server/lib/language_server/experimental/source_file/store.ex index 03effa211..8cf79ea38 100644 --- a/apps/language_server/lib/language_server/experimental/source_file/store.ex +++ b/apps/language_server/lib/language_server/experimental/source_file/store.ex @@ -1,9 +1,11 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do - alias ElixirLS.LanguageServer.Experimental.SourceFile - defmodule State do + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions alias ElixirLS.LanguageServer.Experimental.SourceFile.Store - defstruct source_files: %{} + require Logger + + defstruct source_files: %{}, temp_files: %{}, temporary_open_refs: %{} @type t :: %__MODULE__{} def new do %__MODULE__{} @@ -11,9 +13,9 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do @spec fetch(t, Store.uri()) :: {:ok, SourceFile.t()} | {:error, :not_open} def fetch(%__MODULE__{} = store, uri) do - case Map.fetch(store.source_files, uri) do - :error -> {:error, :not_open} - success -> success + with :error <- Map.fetch(store.source_files, uri), + :error <- Map.fetch(store.temp_files, uri) do + {:error, :not_open} end end @@ -30,6 +32,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do end end + @spec open(t, Store.uri(), String.t(), pos_integer()) :: {:ok, t} | {:error, :already_open} def open(%__MODULE__{} = store, uri, text, version) do case Map.fetch(store.source_files, uri) do {:ok, _} -> @@ -42,6 +45,10 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do end end + def open?(%__MODULE__{} = store, uri) do + Map.has_key?(store.source_files, uri) or Map.has_key?(store.temp_files, uri) + end + def close(%__MODULE__{} = store, uri) do case Map.pop(store.source_files, uri) do {nil, _store} -> @@ -74,10 +81,78 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do end end + def open_temporarily(%__MODULE__{} = store, path_or_uri, timeout) do + uri = Conversions.ensure_uri(path_or_uri) + path = Conversions.ensure_path(path_or_uri) + + with {:ok, contents} <- File.read(path) do + source_file = SourceFile.new(uri, contents, 0) + ref = schedule_unload(uri, timeout) + + new_refs = + store + |> maybe_cancel_old_ref(uri) + |> Map.put(uri, ref) + + temp_files = Map.put(store.temp_files, uri, source_file) + + new_store = %__MODULE__{store | temp_files: temp_files, temporary_open_refs: new_refs} + + {:ok, source_file, new_store} + end + end + + def extend_timeout(%__MODULE__{} = store, uri, timeout) do + case store.temporary_open_refs do + %{^uri => ref} -> + Process.cancel_timer(ref) + new_ref = schedule_unload(uri, timeout) + new_open_refs = Map.put(store.temporary_open_refs, uri, new_ref) + %__MODULE__{store | temporary_open_refs: new_open_refs} + + _ -> + store + end + end + + def unload(%__MODULE__{} = store, uri) do + new_refs = Map.delete(store.temporary_open_refs, uri) + temp_files = Map.delete(store.temp_files, uri) + + %__MODULE__{ + store + | temp_files: temp_files, + temporary_open_refs: new_refs + } + end + + defp maybe_cancel_old_ref(%__MODULE__{} = store, uri) do + {_, new_refs} = + Map.get_and_update(store.temporary_open_refs, uri, fn + nil -> + :pop + + old_ref when is_reference(old_ref) -> + Process.cancel_timer(old_ref) + :pop + end) + + new_refs + end + + defp schedule_unload(uri, timeout) do + Process.send_after(self(), {:unload, uri}, timeout) + end + defp normalize_error(:error), do: {:error, :not_open} defp normalize_error(e), do: e end + alias ElixirLS.LanguageServer.Experimental.ProcessCache + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions + import ElixirLS.LanguageServer.Experimental.Log + @type t :: %State{} @type uri :: String.t() @@ -95,11 +170,27 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do GenServer.call(__MODULE__, {:save, uri}) end + @spec open?(uri()) :: boolean() + def open?(uri) do + GenServer.call(__MODULE__, {:open?, uri}) + end + @spec open(uri(), String.t(), pos_integer()) :: :ok | {:error, :already_open} def open(uri, text, version) do GenServer.call(__MODULE__, {:open, uri, text, version}) end + def open_temporary(uri, timeout \\ 5000) do + path = uri |> Conversions.ensure_path() |> Path.basename() + file_name = Path.basename(path) + + ProcessCache.trans(uri, 50, fn -> + log_and_time "open temporarily: #{file_name}" do + GenServer.call(__MODULE__, {:open_temporarily, uri, timeout}) + end + end) + end + @spec close(uri()) :: :ok | {:error, :not_open} def close(uri) do GenServer.call(__MODULE__, {:close, uri}) @@ -153,6 +244,27 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do {:reply, reply, new_state} end + def handle_call({:open_temporarily, uri, timeout_ms}, _, %State{} = state) do + {reply, new_state} = + with {:error, :not_open} <- State.fetch(state, uri), + {:ok, source_file, new_state} <- State.open_temporarily(state, uri, timeout_ms) do + {{:ok, source_file}, new_state} + else + {:ok, source_file} -> + new_state = State.extend_timeout(state, uri, timeout_ms) + {{:ok, source_file}, new_state} + + error -> + {error, state} + end + + {:reply, reply, new_state} + end + + def handle_call({:open?, uri}, _from, %State{} = state) do + {:reply, State.open?(state, uri), state} + end + def handle_call({:close, uri}, _from, %State{} = state) do {reply, new_state} = case State.close(state, uri) do @@ -182,4 +294,8 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do {:reply, reply, new_state} end + + def handle_info({:unload, uri}, %State{} = state) do + {:noreply, State.unload(state, uri)} + end end diff --git a/apps/language_server/lib/language_server/experimental/supervisor.ex b/apps/language_server/lib/language_server/experimental/supervisor.ex new file mode 100644 index 000000000..0d35acbd8 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/supervisor.ex @@ -0,0 +1,21 @@ +defmodule ElixirLS.LanguageServer.Experimental.Supervisor do + alias ElixirLS.LanguageServer.Experimental + alias ElixirLS.LanguageServer.Experimental.Provider + use Supervisor + + def start_link(init_arg) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl Supervisor + def init(_) do + children = [ + Experimental.SourceFile.Store, + Experimental.Server, + Provider.Queue.Supervisor.child_spec(), + Provider.Queue.child_spec() + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/apps/language_server/lib/language_server/packet_router.ex b/apps/language_server/lib/language_server/packet_router.ex index bbb83f112..9fb3c4fa0 100644 --- a/apps/language_server/lib/language_server/packet_router.ex +++ b/apps/language_server/lib/language_server/packet_router.ex @@ -1,6 +1,6 @@ defmodule ElixirLS.LanguageServer.PacketRouter do defmodule State do - defstruct monitor_references: %{} + defstruct monitor_references: %{}, names_to_pids: %{} def new(names_or_pids) when is_list(names_or_pids) do Enum.reduce(names_or_pids, %__MODULE__{}, &add(&2, &1)) diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index ef67200f4..dc7dd44c2 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -17,6 +17,7 @@ defmodule ElixirLS.LanguageServer.Server do use GenServer require Logger + alias ElixirLS.LanguageServer.Server.Decider alias ElixirLS.LanguageServer.{SourceFile, Build, Protocol, JsonRpc, Dialyzer, Diagnostics} alias ElixirLS.LanguageServer.Providers.{ @@ -168,29 +169,57 @@ defmodule ElixirLS.LanguageServer.Server do end @impl GenServer - def handle_cast({:receive_packet, request(id, _, _) = packet}, state = %__MODULE__{}) do - {:noreply, handle_request_packet(id, packet, state)} + def handle_cast({:receive_packet, request(id, method, _) = packet}, state = %__MODULE__{}) do + new_state = + if Decider.handles?(:standard, method) do + handle_request_packet(id, packet, state) + else + state + end + + {:noreply, new_state} end @impl GenServer def handle_cast({:receive_packet, request(id, method)}, state = %__MODULE__{}) do - {:noreply, handle_request_packet(id, request(id, method, nil), state)} + new_state = + if Decider.handles?(:standard, method) do + handle_request_packet(id, request(id, method, nil), state) + else + state + end + + {:noreply, new_state} end @impl GenServer def handle_cast( - {:receive_packet, notification(_) = packet}, + {:receive_packet, notification(method) = packet}, state = %__MODULE__{received_shutdown?: false, server_instance_id: server_instance_id} ) when is_initialized(server_instance_id) do - {:noreply, handle_notification(packet, state)} + new_state = + if Decider.handles?(:standard, method) do + handle_notification(packet, state) + else + state + end + + {:noreply, new_state} end @impl GenServer def handle_cast({:receive_packet, notification(_) = packet}, state = %__MODULE__{}) do case packet do notification("exit") -> - {:noreply, handle_notification(packet, state)} + new_state = + if Decider.handles?(:standard, "exit") do + handle_notification(packet, state) + else + state + end + + {:noreply, new_state} _ -> {:noreply, state} @@ -514,7 +543,7 @@ defmodule ElixirLS.LanguageServer.Server do end defp handle_request( - initialize_req(_id, root_uri, client_capabilities), + initialize_req(_id, root_uri, client_capabilities) = request, state = %__MODULE__{server_instance_id: server_instance_id} ) when not is_initialized(server_instance_id) do diff --git a/apps/language_server/lib/language_server/server/decider.ex b/apps/language_server/lib/language_server/server/decider.ex new file mode 100644 index 000000000..770a6daca --- /dev/null +++ b/apps/language_server/lib/language_server/server/decider.ex @@ -0,0 +1,24 @@ +defmodule ElixirLS.LanguageServer.Server.Decider do + @moduledoc """ + A module that determines if a message should be handled by + the extant server or the experimental server + """ + alias ElixirLS.LanguageServer.Experimental.LanguageServer, as: ExperimentalLS + import ElixirLS.LanguageServer.JsonRpc, only: [request: 2, notification: 1] + + def handles?(type, notification(method_name)) do + handles?(type, method_name) + end + + def handles?(type, request(_id, method_name)) do + handles?(type, method_name) + end + + def handles?(:standard, method_name) when is_binary(method_name) do + ExperimentalLS.handler_state(method_name) != :exclusive + end + + def handles?(:experimental, method_name) when is_binary(method_name) do + ExperimentalLS.handler_state(method_name) != :ignored + end +end diff --git a/apps/language_server/mix.exs b/apps/language_server/mix.exs index 449e9d1d5..f1368ff08 100644 --- a/apps/language_server/mix.exs +++ b/apps/language_server/mix.exs @@ -33,7 +33,7 @@ defmodule ElixirLS.LanguageServer.Mixfile do {:jason_vendored, github: "elixir-lsp/jason", branch: "vendored"}, {:stream_data, "~> 0.5", only: [:dev, :test], runtime: false}, {:path_glob_vendored, github: "elixir-lsp/path_glob", branch: "vendored"}, - {:patch, "~> 0.12.0", only: :test}, + {:patch, "~> 0.12.0", only: [:dev, :test], runtime: false}, {:benchee, "~> 1.0", only: :dev, runtime: false} ] end diff --git a/apps/language_server/test/experimental/code_unit_test.exs b/apps/language_server/test/experimental/code_unit_test.exs new file mode 100644 index 000000000..69b5ac985 --- /dev/null +++ b/apps/language_server/test/experimental/code_unit_test.exs @@ -0,0 +1,203 @@ +defmodule ElixirLS.LanguageServer.Experimental.CodeUnitTest do + alias ElixirLS.LanguageServer.Experimental.CodeUnit + + use ExUnit.Case + use ExUnitProperties + import CodeUnit + + describe "utf8 offsets" do + test "handles single-byte characters" do + s = "do" + assert 0 == utf8_offset(s, 0) + assert 1 == utf8_offset(s, 1) + assert 2 == utf8_offset(s, 2) + assert 2 == utf8_offset(s, 3) + assert 2 == utf8_offset(s, 4) + end + + test "caps offsets at the end of the string and beyond" do + line = "🎸" + + # reminder, the offsets below are utf-16 + # character code unit offsets, which differ + # from utf8's, and can have gaps. + + assert 4 == utf8_offset(line, 1) + assert 4 == utf8_offset(line, 2) + assert 4 == utf8_offset(line, 3) + assert 4 == utf8_offset(line, 4) + end + + test "handles multi-byte characters properly" do + line = "b🎸abc" + + # reminder, the offsets below are utf-16 + # character code unit offsets, which differ + # from utf8's, and can have gaps. + + assert 0 == utf8_offset(line, 0) + assert 1 == utf8_offset(line, 1) + assert 5 == utf8_offset(line, 3) + assert 6 == utf8_offset(line, 4) + assert 7 == utf8_offset(line, 5) + assert 8 == utf8_offset(line, 6) + assert 8 == utf8_offset(line, 7) + end + end + + describe "utf16_offset/2" do + test "handles single-byte characters" do + s = "do" + assert 0 == utf16_offset(s, 0) + assert 1 == utf16_offset(s, 1) + assert 2 == utf16_offset(s, 2) + assert 2 == utf16_offset(s, 3) + assert 2 == utf16_offset(s, 4) + end + + test "caps offsets at the end of the string and beyond" do + line = "🎸" + assert 2 == utf16_offset(line, 1) + assert 2 == utf16_offset(line, 2) + assert 2 == utf16_offset(line, 3) + assert 2 == utf16_offset(line, 4) + end + + test "handles multi-byte characters properly" do + line = "b🎸abc" + assert 0 == utf16_offset(line, 0) + assert 1 == utf16_offset(line, 1) + assert 3 == utf16_offset(line, 2) + assert 4 == utf16_offset(line, 3) + assert 5 == utf16_offset(line, 4) + assert 6 == utf16_offset(line, 5) + assert 6 == utf16_offset(line, 6) + end + end + + describe "converting to utf8" do + test "bounds are respected" do + assert {:error, :out_of_bounds} = to_utf16("h", 1) + end + + test "with a multi-byte character" do + line = "🏳️‍🌈" + code_unit_count = count_utf8_code_units(line) + + assert to_utf8(line, 0) == {:error, :misaligned} + assert to_utf8(line, 1) == {:ok, 3} + assert to_utf8(line, 2) == {:ok, 6} + assert to_utf8(line, 3) == {:ok, 9} + assert to_utf8(line, 4) == {:error, :misaligned} + assert to_utf8(line, 5) == {:ok, code_unit_count - 1} + end + + test "after a unicode character" do + line = " {\"🎸\", \"ok\"}" + + assert to_utf8(line, 0) == {:ok, 0} + assert to_utf8(line, 1) == {:ok, 1} + assert to_utf8(line, 4) == {:ok, 4} + assert to_utf8(line, 5) == {:ok, 5} + assert to_utf8(line, 6) == {:error, :misaligned} + assert to_utf8(line, 7) == {:ok, 9} + # after the guitar character + assert to_utf8(line, 8) == {:ok, 10} + assert to_utf8(line, 9) == {:ok, 11} + assert to_utf8(line, 10) == {:ok, 12} + assert to_utf8(line, 11) == {:ok, 13} + assert to_utf8(line, 12) == {:ok, 14} + assert to_utf8(line, 13) == {:ok, 15} + assert to_utf8(line, 17) == {:ok, 19} + end + end + + describe "converting to utf16" do + test "respects bounds" do + assert {:error, :out_of_bounds} = to_utf16("h", 1) + end + + test "with a multi-byte character" do + line = "🏳️‍🌈" + code_unit_count = count_utf16_code_units(line) + utf8_code_unit_count = count_utf8_code_units(line) + + assert to_utf16(line, 0) == {:error, :misaligned} + assert to_utf16(line, 1) == {:error, :misaligned} + assert to_utf16(line, 2) == {:error, :misaligned} + assert to_utf16(line, 3) == {:ok, 1} + assert to_utf16(line, 4) == {:error, :misaligned} + assert to_utf16(line, utf8_code_unit_count - 1) == {:ok, code_unit_count - 1} + end + + test "after a multi-byte character" do + line = " {\"🎸\", \"ok\"}" + utf16_code_unit_count = count_utf16_code_units(line) + utf8_code_unit_count = count_utf8_code_units(line) + + # before, the character, there is no difference between utf8 and utf16 + for index <- 0..5 do + assert to_utf16(line, index) == {:ok, index} + end + + assert to_utf16(line, 6) == {:error, :misaligned} + assert to_utf16(line, 7) == {:error, :misaligned} + assert to_utf16(line, 8) == {:error, :misaligned} + + for index <- 9..17 do + assert to_utf16(line, index) == {:ok, index - 2} + end + + assert to_utf16(line, utf8_code_unit_count - 1) == {:ok, utf16_code_unit_count - 1} + end + end + + property "to_utf8 and to_utf16 are inverses of each other" do + check all(s <- filter(string(:printable), &utf8?/1)) do + utf8_code_unit_count = count_utf8_code_units(s) + utf16_unit_count = count_utf16_code_units(s) + + assert {:ok, utf16_unit} = to_utf16(s, utf8_code_unit_count - 1) + assert utf16_unit == utf16_unit_count - 1 + + assert {:ok, utf8_unit} = to_utf8(s, utf16_unit) + assert utf8_unit == utf8_code_unit_count - 1 + end + end + + property "to_utf16 and to_utf8 are inverses" do + check all(s <- filter(string(:printable), &utf8?/1)) do + utf16_code_unit_count = count_utf16_code_units(s) + utf8_code_unit_count = count_utf8_code_units(s) + + assert {:ok, utf8_code_unit} = to_utf8(s, utf16_code_unit_count - 1) + assert utf8_code_unit == utf8_code_unit_count - 1 + + assert {:ok, utf16_unit} = to_utf16(s, utf8_code_unit) + assert utf16_unit == utf16_code_unit_count - 1 + end + end + + defp count_utf16_code_units(utf8_string) do + utf8_string + |> :unicode.characters_to_binary(:utf8, :utf16) + |> byte_size() + |> div(2) + end + + defp count_utf8_code_units(utf8_string) do + byte_size(utf8_string) + end + + defp utf8?(<<_::utf8>>) do + true + end + + defp utf8?(<<_::utf8, rest::binary>>) do + utf8?(rest) + end + + defp utf8?(_) do + false + end +end diff --git a/apps/language_server/test/experimental/format/diff_test.exs b/apps/language_server/test/experimental/format/diff_test.exs new file mode 100644 index 000000000..7bc2e569e --- /dev/null +++ b/apps/language_server/test/experimental/format/diff_test.exs @@ -0,0 +1,149 @@ +defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do + alias ElixirLS.LanguageServer.Experimental.Format.Diff + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Position + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit + + import Diff + use ExUnit.Case + + def edit(start_line, start_code_unit, end_line, end_code_unit, replacement) do + TextEdit.new( + new_text: replacement, + range: + Range.new( + start: Position.new(character: start_code_unit, line: start_line), + end: Position.new(character: end_code_unit, line: end_line) + ) + ) + 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(0, 0, 0, 2, "") == edit + end + + test "appending in the middle" do + orig = "hello" + final = "heyello" + + assert [edit] = diff(orig, final) + assert edit(0, 2, 0, 2, "ye") == edit + end + + test "deleting in the middle" do + orig = "hello" + final = "heo" + + assert [edit] = diff(orig, final) + assert edit(0, 2, 0, 4, "") == edit + 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(0, 3, 0, 5, "vetica went") == edit + 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(0, 0, 2, 0, "") == edit + 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(0, 2, 0, 2, "\n\n ye\n\n") == edit + 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(0, 5, 3, 0, "") == edit + 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(2, 0, 4, 0, "") == edit + end + end + + describe "single line emoji" do + test "deleting after" do + orig = ~S[{"🎸", "after"}] + final = ~S[{"🎸", "after"}] + + assert [edit] = diff(orig, final) + assert edit(0, 7, 0, 9, "") == edit + end + + test "inserting in the middle" do + orig = ~S[🎸🎸] + final = ~S[🎸🎺🎸] + + assert [edit] = diff(orig, final) + assert edit(0, 2, 0, 2, "🎺") == edit + end + + test "deleting in the middle" do + orig = ~S[🎸🎺🎺🎸] + final = ~S[🎸🎸] + assert [edit] = diff(orig, final) + + assert edit(0, 2, 0, 6, "") == edit + end + end + + describe("multi line emoji") do + end +end diff --git a/apps/language_server/test/experimental/formatter_test.exs b/apps/language_server/test/experimental/formatter_test.exs new file mode 100644 index 000000000..c86cbaeec --- /dev/null +++ b/apps/language_server/test/experimental/formatter_test.exs @@ -0,0 +1,74 @@ +defmodule ElixirLS.Experimental.FormatterTest do + alias ElixirLS.LanguageServer.Experimental.Format + alias ElixirLS.LanguageServer.Experimental.SourceFile + + use ExUnit.Case + + def source_file(text) do + SourceFile.new("file://#{__ENV__.file}", text, 1) + end + + def apply_format(text) do + source = source_file(text) + Format.format(source, File.cwd!()) + end + + def elixir_format(text) do + iodata = Code.format_string!(text, []) + + IO.iodata_to_binary([iodata, ?\n]) + end + + def unformatted do + """ + defmodule Unformatted do + def something()do + end + end + """ + end + + describe "format/2" do + test "it should be able to forma a file in the project" do + assert {:ok, formatted} = apply_format(unformatted()) + assert formatted == elixir_format(unformatted()) + end + + test "it should be able to format a file when the project isn't specified" do + assert {:ok, formatted} = unformatted() |> source_file() |> Format.format(nil) + assert formatted == elixir_format(unformatted()) + end + + test "it should provide an error for a syntax error" do + missing_comma = """ + def foo(a, ) do + true + end + """ + + assert {:error, %SyntaxError{}} = apply_format(missing_comma) + end + + test "it should provide an error for a missing token" do + missing_token = """ + defmodule TokenMissing do + :bad + """ + + assert {:error, %TokenMissingError{}} = apply_format(missing_token) + end + + test "it correctly handles unicode" do + orig = """ + {"🎸", "o"} + """ + + expected = """ + {"🎸", "o"} + """ + + assert {:ok, formatted} = apply_format(orig) + assert expected == formatted + end + end +end diff --git a/apps/language_server/test/experimental/process_cache_test.exs b/apps/language_server/test/experimental/process_cache_test.exs new file mode 100644 index 000000000..f1e25fad1 --- /dev/null +++ b/apps/language_server/test/experimental/process_cache_test.exs @@ -0,0 +1,58 @@ +defmodule ElixirLS.LanguageServer.Experimental.ProcessCacheTest do + alias ElixirLS.LanguageServer.Experimental.ProcessCache + import ProcessCache + use ExUnit.Case + use Patch + + setup do + expose(ProcessCache.Entry, now_ts: 0) + {:ok, now: 1} + end + + test "calls the compute function" do + assert 3 == trans("my key", fn -> 3 end) + end + + test "pulls from the process cache when an entry exists" do + assert 3 == trans("my key", fn -> 3 end) + assert 3 == trans("my key", fn -> 6 end) + end + + test "times out after a given timeout", ctx do + now = ctx.now + + patch(ProcessCache.Entry, :now_ts, cycle([now, now + 4999, now + 5000])) + + assert 3 == trans("my key", fn -> 3 end) + assert {:ok, 3} == fetch("my key") + assert :error == fetch("my key") + end + + test "calling get also clears the key after the timeout", ctx do + now = ctx.now + + patch(ProcessCache.Entry, :now_ts, cycle([now, now + 4999, now + 5000])) + + assert 3 == trans("my key", fn -> 3 end) + assert 3 == get("my key") + assert nil == get("my key") + end + + test "the timeout is configurable", ctx do + now = ctx.now + patch(ProcessCache.Entry, :now_ts, cycle([now, now + 49, now + 50])) + + assert 3 = trans("my key", 50, fn -> 3 end) + assert {:ok, 3} == fetch("my key") + assert :error == fetch("my key") + end + + test "trans will replace an expired key", ctx do + now = ctx.now + patch(ProcessCache.Entry, :now_ts, cycle([now, now + 49, now + 50])) + + assert 3 = trans("my key", 50, fn -> 3 end) + assert 3 = trans("my key", 50, fn -> 6 end) + assert 6 = trans("my key", 50, fn -> 6 end) + end +end diff --git a/apps/language_server/test/experimental/project_test.exs b/apps/language_server/test/experimental/project_test.exs new file mode 100644 index 000000000..a00e61f64 --- /dev/null +++ b/apps/language_server/test/experimental/project_test.exs @@ -0,0 +1,325 @@ +defmodule ElixirLS.Experimental.ProjectTest do + alias ElixirLS.LanguageServer.Experimental.Project + alias ElixirLS.LanguageServer.SourceFile + + use ExUnit.Case, async: false + use Patch + + def fixture(opts \\ []) do + signature_help? = Keyword.get(opts, :signature_help?, false) + dynamic_registration? = Keyword.get(opts, :dynamic_registration?, false) + hierarchical_symbols? = Keyword.get(opts, :hierarchical_symbols?, false) + snippet? = Keyword.get(opts, :snippet?, false) + deprecated? = Keyword.get(opts, :deprecated?, false) + tag? = Keyword.get(opts, :tag?, false) + + %{ + "textDocument" => %{ + "signatureHelp" => signature_help?, + "codeAction" => %{"dynamicRegistration" => dynamic_registration?}, + "documentSymbol" => %{"hierarchicalDocumentSymbolSupport" => hierarchical_symbols?}, + "completion" => %{ + "completionItem" => %{ + "snippetSupport" => snippet?, + "deprecatedSupport" => deprecated?, + "tagSupport" => tag? + } + } + } + } + end + + def root_uri do + "file:///tmp/my_project" + end + + def with_a_root_uri(_) do + {:ok, root_uri: "file:///tmp/my_project"} + end + + setup do + patch(File, :cd, :ok) + patch(File, :dir?, true) + patch(Mix, :env, :ok) + :ok + end + + describe "new/2" do + setup [:with_a_root_uri] + + test "should set the root uri" do + patch(File, :cwd, {:ok, SourceFile.Path.absolute_from_uri(root_uri())}) + project = Project.new(root_uri()) + assert project.root_uri == root_uri() + end + + test "should handle a nil root uri" do + project = Project.new(nil) + assert project.root_uri == nil + end + + test "should cd to the root uri if it exists" do + Project.new(root_uri()) + root_path = SourceFile.Path.absolute_from_uri(root_uri()) + + assert_called File.cd(^root_path) + end + + test "shouldn't cd to the root uri if it doesn't exist" do + non_existent_uri = "file:///hopefully/doesn_t/exist" + patch(File, :cd, {:error, :enoent}) + + project = Project.new(non_existent_uri) + + assert project.root_uri == nil + end + end + + def with_a_valid_root_uri(_) do + {:ok, project: Project.new(root_uri())} + end + + describe "changing mix.env" do + setup [:with_a_valid_root_uri] + + test "overwrites an unset env ", ctx do + assert {:ok, %Project{} = project} = Project.change_mix_env(ctx.project, "dev") + + assert project.mix_env == :dev + assert_called Mix.env(:dev) + end + + test "defaults to test", ctx do + assert {:ok, %Project{} = project} = Project.change_mix_env(ctx.project, "") + + assert project.mix_env == :test + assert_called Mix.env(:test) + end + + test "defaults to test with an empty param", ctx do + assert {:ok, %Project{} = project} = Project.change_mix_env(ctx.project, nil) + assert project.mix_env == :test + assert_called Mix.env(:test) + end + + test "with the same mix env has no effect ", ctx do + project = %{ctx.project | mix_env: :dev} + + assert {:ok, %Project{} = project} = Project.change_mix_env(project, "dev") + assert project.mix_env == :dev + refute_called Mix.env(_) + end + + test "overriding with nil has no effect", ctx do + project = %{ctx.project | mix_env: :dev} + + assert {:ok, %Project{} = project} = Project.change_mix_env(project, nil) + assert project.mix_env == :dev + refute_called Mix.env(_) + end + + test "overriding with an emppty string has no effect", ctx do + project = %{ctx.project | mix_env: :dev} + + assert {:ok, %Project{} = project} = Project.change_mix_env(project, "") + assert project.mix_env == :dev + refute_called Mix.env(_) + end + + test "to a new env requires a restar", ctx do + project = %{ctx.project | mix_env: :prod} + + assert {:restart, :warning, message} = Project.change_mix_env(project, "dev") + assert message =~ "Mix env change detected." + refute_called Mix.env(_) + end + end + + def with_patched_system_put_env(_) do + patch(System, :put_env, :ok) + :ok + end + + describe "setting env vars" do + setup [:with_a_valid_root_uri, :with_patched_system_put_env] + + test "sets env vars if it wasn't set", ctx do + vars = %{"first_var" => "first_value", "second_var" => "second_value"} + assert {:ok, %Project{} = project} = Project.change_environment_variables(ctx.project, vars) + + expected_env_vars = %{ + "first_var" => "first_value", + "second_var" => "second_value" + } + + assert project.env_variables == expected_env_vars + assert_called System.put_env(^expected_env_vars) + end + + test "keeps existing env vars if they're the same as the old ones", ctx do + vars = %{"first_var" => "first_value", "second_var" => "second_value"} + project = %Project{ctx.project | env_variables: vars} + + expected_env_vars = %{ + "first_var" => "first_value", + "second_var" => "second_value" + } + + assert {:ok, %Project{} = project} = Project.change_environment_variables(project, vars) + assert project.env_variables == expected_env_vars + refute_called System.put_env(_) + end + + test "rejects env variables that aren't a compatible format", ctx do + vars = ["a", "b", "c"] + + assert {:ok, %Project{} = project} = Project.change_environment_variables(ctx.project, vars) + assert project.env_variables == nil + refute_called System.put_env(_) + end + + test "requires a restart if the variables have been set and are being overridden", ctx do + project = %{ctx.project | env_variables: %{}} + vars = %{"foo" => "6"} + + assert {:restart, :warning, message} = Project.change_environment_variables(project, vars) + assert message =~ "Environment variables have changed" + refute_called System.put_env(_) + end + end + + def with_patched_mix_target(_) do + patch(Mix, :target, :ok) + :ok + end + + describe "setting the mix target" do + setup [:with_a_valid_root_uri, :with_patched_mix_target] + + test "allows you to set the mix target if it was unset", ctx do + assert {:ok, %Project{} = project} = Project.change_mix_target(ctx.project, "local") + assert project.mix_target == :local + assert_called Mix.target(:local) + end + + test "rejects nil for the new target", ctx do + assert {:ok, %Project{} = project} = Project.change_mix_target(ctx.project, nil) + assert project.mix_target == nil + refute_called Mix.target(:local) + end + + test "rejects empty string for the new target", ctx do + assert {:ok, %Project{} = project} = Project.change_mix_target(ctx.project, "") + assert project.mix_target == nil + refute_called Mix.target(:local) + end + + test "does nothing if the mix target is the same as the old target", ctx do + project = %Project{ctx.project | mix_target: :local} + + assert {:ok, %Project{} = project} = Project.change_mix_target(project, "local") + assert project.mix_target == :local + refute_called Mix.target(:local) + end + + test "requires a restart if it was changed after being previously set", ctx do + project = %Project{ctx.project | mix_target: :local} + + assert {:restart, :warning, message} = Project.change_mix_target(project, "docs") + assert message =~ "Mix target change detected." + refute_called Mix.target(_) + end + end + + describe("setting the project dir") do + setup [:with_a_valid_root_uri] + + test "becomes part of the project if the state is empty", ctx do + patch(File, :exists?, fn path, _ -> + String.ends_with?(path, "mix.exs") + end) + + assert {:ok, %Project{} = project} = + Project.change_project_directory(ctx.project, "sub_dir/new/dir") + + assert Project.project_path(project) == "#{File.cwd!()}/sub_dir/new/dir" + assert project.mix_project? + end + + test "only sets the project directory if the root uri is set" do + project = Project.new(nil) + + assert {:ok, project} = Project.change_project_directory(project, "sub_dir/new/dir") + assert project.root_uri == nil + assert Project.project_path(project) == nil + end + + test "defaults to the root uri's directory", ctx do + assert {:ok, project} = Project.change_project_directory(ctx.project, nil) + root_path = SourceFile.Path.absolute_from_uri(project.root_uri) + assert Project.project_path(project) == root_path + end + + test "defaults to the root uri's directory if the project directory is empty", ctx do + assert {:ok, project} = Project.change_project_directory(ctx.project, "") + root_path = SourceFile.Path.absolute_from_uri(project.root_uri) + assert Project.project_path(project) == root_path + end + + test "normalizes the project directory", ctx do + subdirectory = "sub_dir/../sub_dir/new/../new/dir" + + patch(File, :exists?, fn path, _ -> + String.ends_with?(path, "mix.exs") + end) + + assert {:ok, %Project{} = project} = + Project.change_project_directory(ctx.project, subdirectory) + + assert Project.project_path(project) == "#{File.cwd!()}/sub_dir/new/dir" + assert project.mix_project? + assert Project.mix_exs_path(project) == "#{File.cwd!()}/sub_dir/new/dir/mix.exs" + end + + test "sets mix project to false if the mix.exs doesn't exist", ctx do + patch(File, :exists?, fn file_name -> + !String.ends_with?(file_name, "mix.exs") + end) + + assert {:ok, %Project{} = project} = + Project.change_project_directory(ctx.project, "sub_dir/new/dir") + + assert Project.project_path(project) == "#{File.cwd!()}/sub_dir/new/dir" + refute project.mix_project? + end + + test "asks for a restart if the project directory was set and the new one isn't the same", + ctx do + {:ok, project} = Project.change_project_directory(ctx.project, "sub_dir/foo") + + assert {:restart, :warning, message} = + Project.change_project_directory(project, "sub_dir/new/dir") + + assert message =~ "Project directory change detected" + end + + test "shows an error if the project directory doesn't exist", ctx do + {:ok, project} = Project.change_project_directory(ctx.project, "sub_dir/foo") + + patch(File, :dir?, false) + new_directory = "sub_dir/new/dir" + expected_message_directory = Path.join(File.cwd!(), new_directory) + + assert {:error, message} = Project.change_project_directory(project, new_directory) + assert message =~ "Project directory #{expected_message_directory} does not exist" + end + + test "rejects a change if the project directory isn't a subdirectory of the project root", + ctx do + assert {:error, message} = + Project.change_project_directory(ctx.project, "../../../../not-a-subdir") + + assert message =~ "is not a subdirectory of" + end + end +end diff --git a/apps/language_server/test/experimental/protocol/proto_test.exs b/apps/language_server/test/experimental/protocol/proto_test.exs index dbb5a966d..0e64541cc 100644 --- a/apps/language_server/test/experimental/protocol/proto_test.exs +++ b/apps/language_server/test/experimental/protocol/proto_test.exs @@ -248,9 +248,10 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do use Proto defnotification "textDocument/somethingHappened", - line: integer(), - notice_message: string(), - column: integer() + :exlusive, + line: integer(), + notice_message: string(), + column: integer() end test "parse fills out the notification" do @@ -293,7 +294,8 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do use Proto defnotification "notif/withTextDoc", - text_document: Types.TextDocument + :exclusive, + text_document: Types.TextDocument end test "to_elixir fills out the source file", ctx do @@ -307,8 +309,9 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do use Proto defnotification "notif/WithPos", - text_document: Types.TextDocument, - position: Types.Position + :exclusive, + text_document: Types.TextDocument, + position: Types.Position end test "to_elixir fills out a position", ctx do @@ -331,8 +334,9 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do use Proto defnotification "notif/WithPos", - text_document: Types.TextDocument, - range: Types.Range + :exclusive, + text_document: Types.TextDocument, + range: Types.Range end test "to_elixir fills out a range", ctx do @@ -363,19 +367,19 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do defmodule Req do use Proto - defrequest "something", line: integer(), error_message: string() + defrequest "something", :exclusive, line: integer(), error_message: string() end defmodule TextDocReq do use Proto - defrequest "textDoc", text_document: Types.TextDocument + defrequest "textDoc", :exclusive, text_document: Types.TextDocument end test "parse fills out the request" do assert {:ok, params} = params_for(Req, id: 3, line: 9, error_message: "borked") assert {:ok, req} = Req.parse(params) - assert req.id == 3 + assert req.id == "3" assert req.method == "something" assert req.jsonrpc == "2.0" assert req.lsp.line == 9 @@ -417,7 +421,7 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do defmodule PositionReq do use Proto - defrequest "posReq", text_document: Types.TextDocument, position: Types.Position + defrequest "posReq", :exclusive, text_document: Types.TextDocument, position: Types.Position end test "to_elixir fills out a position", ctx do @@ -440,7 +444,7 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do defmodule RangeReq do use Proto - defrequest "rangeReq", text_document: Types.TextDocument, range: Types.Range + defrequest "rangeReq", :exclusive, text_document: Types.TextDocument, range: Types.Range end test "to_elixir fills out a range", ctx do diff --git a/apps/language_server/test/experimental/provider/handlers/find_references_test.exs b/apps/language_server/test/experimental/provider/handlers/find_references_test.exs new file mode 100644 index 000000000..583cfcccd --- /dev/null +++ b/apps/language_server/test/experimental/provider/handlers/find_references_test.exs @@ -0,0 +1,234 @@ +defmodule ElixirLS.Experimental.Provider.Handlers.FindReferencesTest do + use ExUnit.Case, async: false + + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.FindReferences + alias ElixirLS.LanguageServer.Experimental.Protocol.Types + alias ElixirLS.LanguageServer.Experimental.Protocol.Responses + alias ElixirLS.LanguageServer.Experimental.Provider.Env + alias ElixirLS.LanguageServer.Experimental.Provider.Handlers + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions + + alias ElixirLS.LanguageServer.Fixtures.LspProtocol + alias ElixirLS.LanguageServer.Test.FixtureHelpers + alias ElixirLS.LanguageServer.Tracer + alias ElixirLS.LanguageServer.Build + + import LspProtocol + import ElixirLS.Test.TextLoc, only: [annotate_assert: 4] + require ElixirLS.Test.TextLoc + + @fixtures_to_load [ + "references_referenced.ex", + "references_imported.ex", + "references_remote.ex", + "uses_macro_a.ex", + "macro_a.ex" + ] + + setup_all do + File.rm_rf!(FixtureHelpers.get_path(".elixir_ls/calls.dets")) + {:ok, _} = start_supervised(Tracer) + + Tracer.set_project_dir(FixtureHelpers.get_path("")) + + compiler_options = Code.compiler_options() + Build.set_compiler_options(ignore_module_conflict: true) + + on_exit(fn -> + Code.compiler_options(compiler_options) + end) + + names_to_paths = + for file <- @fixtures_to_load, + path = FixtureHelpers.get_path(file), + into: %{} do + Code.compile_file(path) + {file, path} + end + + {:ok, paths: names_to_paths} + end + + setup do + {:ok, _} = start_supervised(SourceFile.Store) + :ok + end + + def request(file_path, line, char) do + uri = Conversions.ensure_uri(file_path) + + params = [ + text_document: [uri: uri], + position: [line: line, character: char] + ] + + with {:ok, contents} <- File.read(file_path), + :ok <- SourceFile.Store.open(uri, contents, 1), + {:ok, _source_file} <- SourceFile.Store.fetch(uri), + {:ok, req} <- build(FindReferences, params) do + FindReferences.to_elixir(req) + end + end + + def handle(request) do + Handlers.FindReferences.handle(request, Env.new()) + end + + test "finds local, remote and imported references to a function", ctx do + line = 1 + char = 8 + file_path = ctx.paths["references_referenced.ex"] + {:ok, request} = request(file_path, line, char) + + annotate_assert(file_path, line, char, """ + def referenced_fun do + ^ + """) + + {:reply, %Responses.FindReferences{result: references}} = handle(request) + + assert length(references) == 3 + assert Enum.any?(references, &String.ends_with?(&1.uri, "references_remote.ex")) + assert Enum.any?(references, &String.ends_with?(&1.uri, "references_imported.ex")) + assert Enum.any?(references, &String.ends_with?(&1.uri, "references_referenced.ex")) + end + + test "finds local, remote and imported references to a macro", ctx do + line = 8 + char = 12 + + file_path = ctx.paths["references_referenced.ex"] + {:ok, request} = request(file_path, line, char) + + annotate_assert(file_path, line, char, """ + defmacro referenced_macro(clause, do: expression) do + ^ + """) + + {:reply, %Responses.FindReferences{result: references}} = handle(request) + + assert length(references) == 3 + + assert Enum.any?(references, &String.ends_with?(&1.uri, "references_remote.ex")) + assert Enum.any?(references, &String.ends_with?(&1.uri, "references_imported.ex")) + assert Enum.any?(references, &String.ends_with?(&1.uri, "references_referenced.ex")) + end + + test "find a references to a macro generated function call", ctx do + line = 6 + char = 13 + + file_path = ctx.paths["uses_macro_a.ex"] + + annotate_assert(file_path, line, char, """ + macro_a_func() + ^ + """) + + {:ok, request} = request(file_path, line, char) + uri = request.source_file.uri + + {:reply, %Responses.FindReferences{result: result}} = handle(request) + + assert [location] = result + + %Types.Location{ + range: %Types.Range{ + start: %Types.Position{character: 4, line: 6}, + end: %Types.Position{character: 16, line: 6} + }, + uri: ^uri + } = location + end + + test "finds a references to a macro imported function call", ctx do + line = 10 + char = 4 + + file_path = ctx.paths["uses_macro_a.ex"] + + {:ok, request} = request(file_path, line, char) + + uri = request.source_file.uri + + annotate_assert(file_path, line, char, """ + macro_imported_fun() + ^ + """) + + {:reply, %Responses.FindReferences{result: [reference]}} = handle(request) + + assert %Types.Location{ + range: %Types.Range{ + start: %Types.Position{line: 10, character: 4}, + end: %Types.Position{line: 10, character: 22} + }, + uri: ^uri + } = reference + end + + test "finds references to a variable", ctx do + line = 4 + char = 14 + file_path = ctx.paths["references_referenced.ex"] + + annotate_assert(file_path, line, char, """ + IO.puts(referenced_variable + 1) + ^ + """) + + assert {:ok, request} = request(file_path, line, char) + uri = request.source_file.uri + + {:reply, %Responses.FindReferences{result: [first, second]}} = handle(request) + + assert %Types.Location{ + uri: ^uri, + range: %Types.Range{ + start: %Types.Position{character: 4, line: 2}, + end: %Types.Position{character: 23, line: 2} + } + } = first + + assert %Types.Location{ + range: %Types.Range{ + start: %Types.Position{character: 12, line: 4}, + end: %Types.Position{character: 31, line: 4} + } + } = second + end + + test "finds references to an attribute", ctx do + line = 24 + char = 5 + file_path = ctx.paths["references_referenced.ex"] + + annotate_assert(file_path, line, char, """ + @referenced_attribute \"123\" + ^ + """) + + {:ok, request} = request(file_path, line, char) + + {:reply, %Responses.FindReferences{result: [first, second]}} = handle(request) + + uri = request.source_file.uri + + assert %Types.Location{ + uri: ^uri, + range: %Types.Range{ + start: %Types.Position{character: 2, line: 24}, + end: %Types.Position{character: 23, line: 24} + } + } = first + + assert %Types.Location{ + uri: ^uri, + range: %Types.Range{ + start: %Types.Position{character: 4, line: 27}, + end: %Types.Position{character: 25, line: 27} + } + } = second + end +end diff --git a/apps/language_server/test/experimental/provider/handlers/formatting_test.exs b/apps/language_server/test/experimental/provider/handlers/formatting_test.exs new file mode 100644 index 000000000..16883350d --- /dev/null +++ b/apps/language_server/test/experimental/provider/handlers/formatting_test.exs @@ -0,0 +1,3 @@ +defmodule ElixriLS.LanguageServer.Experimental.Provider.Handlers.FormattingTest do + use ExUnit.Case +end diff --git a/apps/language_server/test/experimental/provider/queue_test.exs b/apps/language_server/test/experimental/provider/queue_test.exs new file mode 100644 index 000000000..ecaef9b67 --- /dev/null +++ b/apps/language_server/test/experimental/provider/queue_test.exs @@ -0,0 +1,101 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.QueueTest do + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests + alias ElixirLS.LanguageServer.Experimental.Protocol.Responses + alias ElixirLS.LanguageServer.Experimental.Provider + alias ElixirLS.LanguageServer.Experimental.Provider.Env + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Tracer + alias ElixirLS.Utils.WireProtocol + + import ElixirLS.LanguageServer.Fixtures.LspProtocol + use ExUnit.Case + use Patch + + setup do + {:ok, _} = start_supervised(Provider.Queue.Supervisor.child_spec()) + {:ok, _} = start_supervised(Provider.Queue) + {:ok, _} = start_supervised(Tracer) + + {:ok, env: %Env{}} + end + + def with_patched_store(_) do + patch(SourceFile.Store, :fetch, fn uri -> + source = """ + defmodule MyModule do + end + """ + + source_file = SourceFile.new(uri, source, 1) + {:ok, source_file} + end) + + :ok + end + + def with_redirected_replies(_) do + me = self() + + patch(WireProtocol, :send, fn message -> + send(me, {:wire_protocol, message}) + end) + + :ok + end + + describe "the request queue" do + setup [:with_patched_store, :with_redirected_replies] + + test "handles a find references request", ctx do + {:ok, request} = + build(Requests.FindReferences, + id: 1, + text_document: [uri: "file:///file.ex", position: [line: 0, character: 5]] + ) + + assert :ok = Provider.Queue.add(request, ctx.env) + assert_receive {:wire_protocol, %Responses.FindReferences{id: "1"}} + end + + test "can cancel requests", ctx do + patch(Provider.Handlers.FindReferences, :handle, fn -> + Process.sleep(250) + {:reply, Responses.FindReferences.new(1, [])} + end) + + {:ok, request} = + build(Requests.FindReferences, + id: 1, + text_document: [uri: "file:///file.ex", position: [line: 0, character: 0]] + ) + + assert :ok = Provider.Queue.add(request, ctx.env) + assert :ok = Provider.Queue.cancel(request.id) + + refute_receive {:wire_protocol, _} + end + + test "knows if a request is running", ctx do + patch(Provider.Handlers.FindReferences, :handle, fn -> + Process.sleep(250) + {:reply, Responses.FindReferences.new(1, [])} + end) + + {:ok, request} = + build(Requests.FindReferences, + id: 1, + text_document: [uri: "file:///file.ex", position: [line: 0, character: 0]] + ) + + assert :ok = Provider.Queue.add(request, ctx.env) + assert Provider.Queue.running?(request) + assert Provider.Queue.running?(request.id) + + assert_receive {:wire_protocol, _} + Process.sleep(100) + + refute Provider.Queue.running?(request) + refute Provider.Queue.running?(request.id) + end + end +end diff --git a/apps/language_server/test/experimental/server/configuration_test.exs b/apps/language_server/test/experimental/server/configuration_test.exs new file mode 100644 index 000000000..394c18428 --- /dev/null +++ b/apps/language_server/test/experimental/server/configuration_test.exs @@ -0,0 +1,272 @@ +defmodule ElixirLS.Experimental.Server.ConfigurationTest do + alias ElixirLS.LanguageServer.Dialyzer + alias ElixirLS.LanguageServer.Experimental.Project + alias ElixirLS.LanguageServer.Experimental.Protocol.Notifications.DidChangeConfiguration + alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.LspTypes.Registration + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.RegisterCapability + alias ElixirLS.LanguageServer.Experimental.Server.Configuration + alias ElixirLS.LanguageServer.SourceFile + + use ExUnit.Case, async: false + use Patch + + def fixture(opts \\ []) do + signature_help? = Keyword.get(opts, :signature_help?, false) + dynamic_registration? = Keyword.get(opts, :dynamic_registration?, false) + hierarchical_symbols? = Keyword.get(opts, :hierarchical_symbols?, false) + snippet? = Keyword.get(opts, :snippet?, false) + deprecated? = Keyword.get(opts, :deprecated?, false) + tag? = Keyword.get(opts, :tag?, false) + + %{ + "textDocument" => %{ + "signatureHelp" => signature_help?, + "codeAction" => %{"dynamicRegistration" => dynamic_registration?}, + "documentSymbol" => %{"hierarchicalDocumentSymbolSupport" => hierarchical_symbols?}, + "completion" => %{ + "completionItem" => %{ + "snippetSupport" => snippet?, + "deprecatedSupport" => deprecated?, + "tagSupport" => tag? + } + } + } + } + end + + def root_uri do + "file:///tmp/my_project" + end + + def with_a_root_uri(_) do + {:ok, root_uri: "file:///tmp/my_project"} + end + + setup do + patch(File, :cd, :ok) + patch(File, :dir?, true) + patch(Mix, :env, :ok) + :ok + end + + describe "new/2" do + setup [:with_a_root_uri] + + test "should set the root uri" do + patch(File, :cwd, {:ok, SourceFile.Path.absolute_from_uri(root_uri())}) + config = Configuration.new(root_uri(), fixture()) + assert config.project.root_uri == root_uri() + end + + test "should handle a nil root uri" do + config = Configuration.new(nil, fixture()) + assert config.project.root_uri == nil + end + + test "should cd to the root uri if it exists" do + Configuration.new(root_uri(), fixture()) + root_path = SourceFile.Path.absolute_from_uri(root_uri()) + + assert_called File.cd(^root_path) + end + + test "shouldn't cd to the root uri if it doesn't exist" do + non_existent_uri = "file:///hopefully/doesn_t/exist" + patch(File, :cd, {:error, :enoent}) + + config = Configuration.new(non_existent_uri, fixture()) + + assert config.project.root_uri == nil + end + + test "should read dynamic registration" do + pos_config = Configuration.new(root_uri(), fixture(dynamic_registration?: true)) + neg_config = Configuration.new(root_uri(), fixture(dynamic_registration?: false)) + + assert pos_config.support.code_action_dynamic_registration? + refute neg_config.support.code_action_dynamic_registration? + refute Configuration.new(root_uri(), %{}).support.code_action_dynamic_registration? + end + + test "it should support signature_help" do + assert Configuration.new(root_uri(), fixture(signature_help?: true)).support.signature_help? + + refute Configuration.new(root_uri(), fixture(signature_help?: false)).support.signature_help? + + refute Configuration.new(root_uri(), %{}).support.signature_help? + end + + test "it should support hierarchical registration" do + assert Configuration.new(root_uri(), fixture(hierarchical_symbols?: true)).support.hierarchical_document_symbols? + + refute Configuration.new(root_uri(), fixture(hierarchical_symbols?: false)).support.hierarchical_document_symbols? + + refute Configuration.new(root_uri(), %{}).support.hierarchical_document_symbols? + end + + test "it should support snippets" do + assert Configuration.new(root_uri(), fixture(snippet?: true)).support.snippet? + refute Configuration.new(root_uri(), fixture(snippet?: false)).support.snippet? + refute Configuration.new(root_uri(), %{}).support.snippet? + end + + test "it should support deprecated" do + assert Configuration.new(root_uri(), fixture(deprecated?: true)).support.deprecated? + refute Configuration.new(root_uri(), fixture(deprecated?: false)).support.deprecated? + refute Configuration.new(root_uri(), %{}).support.deprecated? + end + + test "it should support tags" do + assert Configuration.new(root_uri(), fixture(deprecated?: true)).support.deprecated? + refute Configuration.new(root_uri(), fixture(deprecated?: false)).support.deprecated? + refute Configuration.new(root_uri(), %{}).support.deprecated? + end + end + + def with_an_empty_config(_) do + {:ok, config: Configuration.new(root_uri(), %{})} + end + + describe "changing mix.env" do + setup [:with_an_empty_config] + + test "overwrites an unset env ", ctx do + change = DidChangeConfiguration.new(settings: %{"mixEnv" => "dev"}) + assert {:ok, %Configuration{} = config} = Configuration.on_change(ctx.config, change) + + assert config.project.mix_env == :dev + assert_called Mix.env(:dev) + end + + test "defaults to test", ctx do + change = DidChangeConfiguration.new(settings: %{}) + assert {:ok, %Configuration{} = config} = Configuration.on_change(ctx.config, change) + + assert config.project.mix_env == :test + assert_called Mix.env(:test) + end + end + + def with_patched_system_put_env(_) do + patch(System, :put_env, :ok) + :ok + end + + describe "setting env vars" do + setup [:with_an_empty_config, :with_patched_system_put_env] + + test "overwrites existing env vars if it wasn't set", ctx do + vars = %{"first_var" => "first_value", "second_var" => "second_value"} + + change = DidChangeConfiguration.new(settings: %{"envVariables" => vars}) + assert {:ok, %Configuration{} = config} = Configuration.on_change(ctx.config, change) + + expected_env_vars = %{ + "first_var" => "first_value", + "second_var" => "second_value" + } + + assert config.project.env_variables == expected_env_vars + assert_called System.put_env(^expected_env_vars) + end + end + + def with_patched_mix_target(_) do + patch(Mix, :target, :ok) + :ok + end + + describe "setting the mix target" do + setup [:with_an_empty_config, :with_patched_mix_target] + + test "allows you to set the mix target if it was unset", ctx do + change = DidChangeConfiguration.new(settings: %{"mixTarget" => "local"}) + + assert {:ok, %Configuration{} = config} = Configuration.on_change(ctx.config, change) + assert config.project.mix_target == :local + assert_called Mix.target(:local) + end + end + + describe("setting the project dir") do + setup [:with_an_empty_config] + + test "becomes part of the project if the state is empty", ctx do + change = DidChangeConfiguration.new(settings: %{"projectDir" => "sub_dir/new/dir"}) + + assert {:ok, %Configuration{} = config} = Configuration.on_change(ctx.config, change) + assert Project.project_path(config.project) == "#{File.cwd!()}/sub_dir/new/dir" + end + + test "only sets the project directory if the root uri is set" do + config = Configuration.new(nil, fixture()) + change = DidChangeConfiguration.new(settings: %{"projectDir" => "sub_dir/new/dir"}) + + assert {:ok, config} = Configuration.on_change(config, change) + assert config.project.root_uri == nil + assert Project.project_path(config.project) == nil + end + end + + def with_patched_dialyzer_support(_) do + patch(Dialyzer, :check_support, :ok) + :ok + end + + describe("setting dialyzer being enabled") do + setup [:with_an_empty_config, :with_patched_dialyzer_support] + + test "it can be enabled if it is supported", ctx do + refute ctx.config.dialyzer_enabled? + change = DidChangeConfiguration.new(settings: %{"dialyzer_enabled" => true}) + + assert {:ok, config} = Configuration.on_change(ctx.config, change) + assert config.dialyzer_enabled? + end + + test "it should be on by default", ctx do + change = DidChangeConfiguration.new(settings: %{}) + + assert {:ok, config} = Configuration.on_change(ctx.config, change) + assert config.dialyzer_enabled? + end + + test "if dialyzer is not supported, it can't be turned on", ctx do + patch(Dialyzer, :check_support, {:error, "Dialyzer is broken"}) + change = DidChangeConfiguration.new(settings: %{"dialyzer_enabled" => true}) + + assert {:ok, config} = Configuration.on_change(ctx.config, change) + refute config.dialyzer_enabled? + end + end + + describe("setting watched extensions") do + setup [:with_an_empty_config] + + test "it returns the state if no extenstions are given", ctx do + config = ctx.config + change = DidChangeConfiguration.new(settings: %{"additionalWatchedExtensions" => []}) + # ensuring it didn't send back any messages for us to process + assert {:ok, _} = Configuration.on_change(config, change) + end + + test "it returns a register capability request with watchers for each extension", ctx do + config = ctx.config + + change = + DidChangeConfiguration.new( + settings: %{"additionalWatchedExtensions" => [".ex3", ".heex"]} + ) + + assert {:ok, _config, %RegisterCapability{} = watch_request} = + Configuration.on_change(config, change) + + assert [%Registration{} = registration] = watch_request.lsp.registrations + assert registration.method == "workspace/didChangeWatchedFiles" + + assert %{"watchers" => watchers} = registration.register_options + assert %{"globPattern" => "**/*.ex3"} in watchers + assert %{"globPattern" => "**/*.heex"} in watchers + end + end +end diff --git a/apps/language_server/test/experimental/server/state_test.exs b/apps/language_server/test/experimental/server/state_test.exs index bc30ad468..b06184a62 100644 --- a/apps/language_server/test/experimental/server/state_test.exs +++ b/apps/language_server/test/experimental/server/state_test.exs @@ -1,4 +1,5 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.StateTest do + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.Initialize alias ElixirLS.LanguageServer.Experimental.Protocol.Notifications alias ElixirLS.LanguageServer.Experimental.SourceFile alias ElixirLS.LanguageServer.Experimental.Server.State @@ -16,6 +17,13 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.StateTest do "file:///file.ex" end + def initialized_state do + {:ok, initialize} = build(Initialize, root_uri: "file:///my_project", capabilities: %{}) + {:ok, state} = State.initialize(State.new(), initialize) + + state + end + def with_an_open_document(_) do {:ok, did_open} = build(Notifications.DidOpen, @@ -23,7 +31,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.StateTest do text_document: [uri: uri(), version: 1, text: "hello"] ) - {:ok, state} = State.apply(State.new(), did_open) + {:ok, state} = State.apply(initialized_state(), did_open) {:ok, state: state} end @@ -51,12 +59,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.StateTest do test "closing a document that isn't open fails" do {:ok, did_close} = build(Notifications.DidClose, text_document: [uri: uri()]) - assert {:error, :not_open} = State.apply(State.new(), did_close) + assert {:error, :not_open} = State.apply(initialized_state(), did_close) end test "saving a document that isn't open fails" do {:ok, save} = build(Notifications.DidSave, text_document: [uri: uri()]) - assert {:error, :not_open} = State.apply(State.new(), save) + assert {:error, :not_open} = State.apply(initialized_state(), save) end test "applying a didOpen notification" do @@ -68,7 +76,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.StateTest do text_document: [uri: uri(), version: 1, text: "hello"] ) - {:ok, _state} = State.apply(State.new(), did_open) + {:ok, _state} = State.apply(initialized_state(), did_open) assert {:ok, file} = SourceFile.Store.fetch(uri()) assert SourceFile.to_string(file) == "hello" assert file.version == 1 diff --git a/apps/language_server/test/experimental/source_file/store_test.exs b/apps/language_server/test/experimental/source_file/store_test.exs index b195bf397..73b63d509 100644 --- a/apps/language_server/test/experimental/source_file/store_test.exs +++ b/apps/language_server/test/experimental/source_file/store_test.exs @@ -4,6 +4,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.StoreTest do import ElixirLS.LanguageServer.Fixtures.LspProtocol use ExUnit.Case + use Patch setup do {:ok, _} = start_supervised(SourceFile.Store) @@ -95,4 +96,49 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.StoreTest do ) end end + + def with_a_temp_document(_) do + contents = """ + defmodule FakeDocument do + end + """ + + patch(File, :read, fn _uri -> + {:ok, contents} + end) + + {:ok, contents: contents, uri: "file:///file.ex"} + end + + describe "a temp document" do + setup [:with_a_temp_document] + + test "can be opened", ctx do + assert {:ok, doc} = SourceFile.Store.open_temporary(ctx.uri, 100) + assert SourceFile.to_string(doc) == ctx.contents + end + + test "closes after a timeout", ctx do + assert {:ok, _} = SourceFile.Store.open_temporary(ctx.uri, 100) + Process.sleep(101) + refute SourceFile.Store.open?(ctx.uri) + end + + test "the extension is extended on subsequent access", ctx do + assert {:ok, _doc} = SourceFile.Store.open_temporary(ctx.uri, 100) + Process.sleep(75) + assert {:ok, _} = SourceFile.Store.open_temporary(ctx.uri, 100) + Process.sleep(75) + assert SourceFile.Store.open?(ctx.uri) + Process.sleep(50) + refute SourceFile.Store.open?(ctx.uri) + end + + test "opens permanently when a call to open is made", ctx do + assert {:ok, _doc} = SourceFile.Store.open_temporary(ctx.uri, 100) + assert :ok = SourceFile.Store.open(ctx.uri, ctx.contents, 1) + Process.sleep(120) + assert SourceFile.Store.open?(ctx.uri) + end + end end diff --git a/apps/language_server/test/experimental/source_file_test.exs b/apps/language_server/test/experimental/source_file_test.exs index c2737cc42..58b3d6037 100644 --- a/apps/language_server/test/experimental/source_file_test.exs +++ b/apps/language_server/test/experimental/source_file_test.exs @@ -1,11 +1,14 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFileTest do - use ExUnit.Case, async: true + alias ElixirLS.LanguageServer.Experimental + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Position + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextDocument.ContentChangeEvent + alias ElixirLS.LanguageServer.SourceFile + use ExUnit.Case use ExUnitProperties use Patch - alias ElixirLS.LanguageServer.Experimental - alias ElixirLS.LanguageServer.SourceFile import ElixirLS.LanguageServer.Experimental.SourceFile, except: [to_string: 1] def text(%Experimental.SourceFile{} = source) do @@ -571,6 +574,67 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFileTest do assert "foooo\nbaz🏳️‍🌈z\nbaz" == text(source) end + test "works with a content change event" do + orig = """ + defmodule LanguageServer.Experimental.Server.Test do + def foo do + {"🎸", "other"} + end + end + """ + + event = + ContentChangeEvent.new( + text: "", + range: + Range.new( + start: Position.new(character: 0, line: 2), + end: Position.new(character: 22, line: 2) + ) + ) + + assert {:ok, source} = run_changes(orig, [event]) + assert {:ok, ""} = fetch_text_at(source, 3) + end + + test "deleting a line with a multi-byte character" do + orig = """ + defmodule LanguageServer.Experimental.Server.Test do + def foo do + {"🎸", "other"} + end + end + """ + + assert {:ok, source} = + run_changes(orig, [ + %{"text" => "", "range" => range_create(2, 0, 2, 19)} + ]) + + {:ok, line} = fetch_text_at(source, 3) + assert line == "" + end + + test "inserting a line with unicode" do + orig = """ + defmodule MyModule do + def func do + + end + end + """ + + assert {:ok, source} = + run_changes(orig, [ + %{"text" => " {\"🎸\", \"ok\"}", "range" => range_create(2, 0, 2, 0)}, + %{"text" => "", "range" => range_create(2, 11, 2, 13)} + ]) + + {:ok, line} = fetch_text_at(source, 3) + + assert line == " {\"🎸\", \"ok\"}" + end + test "invalid update range - before the document starts -> before the document starts" do orig = "foo\nbar" invalid_range = range_create(-2, 0, -1, 3) diff --git a/apps/language_server/test/support/fixtures/lsp_protocol.ex b/apps/language_server/test/support/fixtures/lsp_protocol.ex index 8ef3680c1..01b4d8c1c 100644 --- a/apps/language_server/test/support/fixtures/lsp_protocol.ex +++ b/apps/language_server/test/support/fixtures/lsp_protocol.ex @@ -22,8 +22,13 @@ defmodule ElixirLS.LanguageServer.Fixtures.LspProtocol do Keyword.put(args, :method, module_to_build.__meta__(:method_name)) {:request, _} -> + id = + opts + |> Keyword.get(:id, next_int()) + |> to_string() + args - |> Keyword.put(:id, Keyword.get(opts, :id, next_int())) + |> Keyword.put(:id, id) |> Keyword.put(:method, module_to_build.__meta__(:method_name)) _ -> @@ -86,11 +91,16 @@ defmodule ElixirLS.LanguageServer.Fixtures.LspProtocol do } {:request, _} -> + id = + opts + |> Keyword.get(:id, next_int()) + |> to_string() + %{ jsonrpc: Keyword.get(opts, :jsonrpc, "2.0"), method: proto_module.__meta__(:method_name), params: extract_params(proto_struct), - id: Keyword.get(opts, :id, next_int()) + id: id } _other -> diff --git a/config/config.exs b/config/config.exs index 674a41203..ad4875cc7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -15,3 +15,15 @@ import Config # level: :info, # format: "$date $time [$level] $metadata$message\n", # metadata: [:user_id] + +env_bool = fn name -> + enabled_str = + name + |> System.get_env("false") + |> String.downcase() + + enabled_str == "true" +end + +config :language_server, + enable_experimental_server: env_bool.("ENABLE_EXPERIMENTAL_SERVER") From 74be499b9798fa37f36864a69e7d9d3b9c5e178b Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Mon, 21 Nov 2022 11:28:19 -0800 Subject: [PATCH 02/21] Add underscore code action Created a code action that prepends an underscore to unused variable names. --- .../language_server/experimental/code_unit.ex | 21 ++ .../experimental/format/diff.ex | 9 +- .../experimental/protocol/proto/convert.ex | 76 ++++- .../protocol/proto/macros/json.ex | 13 +- .../experimental/protocol/requests.ex | 25 +- .../experimental/protocol/responses.ex | 6 + .../experimental/protocol/types.ex | 69 +++++ .../code_action/remove_unused_variable.ex | 0 .../code_action/replace_with_underscore.ex | 125 ++++++++ .../experimental/provider/env.ex | 7 +- .../provider/handlers/code_action.ex | 16 + .../experimental/provider/queue.ex | 3 +- .../experimental/server/configuration.ex | 2 - .../experimental/source_file.ex | 2 +- .../experimental/source_file/document.ex | 23 +- .../test/experimental/protocol/proto_test.exs | 14 +- .../provider/code_action/#warning_parser.ex# | 59 ++++ .../replace_with_underscore_test.exs | 278 ++++++++++++++++++ .../provider/handlers/code_action.ex | 13 + 19 files changed, 721 insertions(+), 40 deletions(-) create mode 100644 apps/language_server/lib/language_server/experimental/provider/code_action/remove_unused_variable.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/handlers/code_action.ex create mode 100644 apps/language_server/test/experimental/provider/code_action/#warning_parser.ex# create mode 100644 apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs create mode 100644 apps/language_server/test/experimental/provider/handlers/code_action.ex diff --git a/apps/language_server/lib/language_server/experimental/code_unit.ex b/apps/language_server/lib/language_server/experimental/code_unit.ex index 6d567b9ee..14ad0b822 100644 --- a/apps/language_server/lib/language_server/experimental/code_unit.ex +++ b/apps/language_server/lib/language_server/experimental/code_unit.ex @@ -52,10 +52,31 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeUnit do do_to_utf16(binary, utf16_unit + 1, 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 diff --git a/apps/language_server/lib/language_server/experimental/format/diff.ex b/apps/language_server/lib/language_server/experimental/format/diff.ex index 4e7a84783..2e28659f7 100644 --- a/apps/language_server/lib/language_server/experimental/format/diff.ex +++ b/apps/language_server/lib/language_server/experimental/format/diff.ex @@ -1,4 +1,5 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.Diff do + alias ElixirLS.LanguageServer.Experimental.CodeUnit alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Position alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit @@ -79,16 +80,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.Diff do end def advance(<>, {line, unit}) do - increment = utf16_code_units(<>) + increment = CodeUnit.count(:utf16, <>) advance(rest, {line, unit + increment}) end - def utf16_code_units(<<_::utf16>> = utf16_grapheme) do - utf16_grapheme - |> byte_size() - |> div(2) - end - defp edit(text, start_line, start_unit, end_line, end_unit) do TextEdit.new( new_text: text, diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex index 48c3f8b07..10afa6873 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex @@ -1,19 +1,26 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert do + alias ElixirLS.LanguageServer.SourceFile + alias ElixirLS.LanguageServer.Experimental.Protocol.Types alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions alias ElixirLS.LanguageServer.Experimental.SourceFile - def to_elixir(%{text_document: _} = request) do - with {:ok, source_file} <- fetch_source_file(request.lsp), - {:ok, updates} <- convert(request.lsp, source_file) do + def to_elixir(%{lsp: lsp_request} = request) do + with {:ok, elixir_request, source_file} <- convert(lsp_request) do updated_request = - request - |> Map.put(:source_file, source_file) - |> Map.merge(updates) + case Map.merge(request, Map.from_struct(elixir_request)) do + %_{source_file: _} = updated -> Map.put(updated, :source_file, source_file) + updated -> updated + end {:ok, updated_request} end end + def to_elixir(%_request_module{lsp: lsp_request} = request) do + converted = Map.merge(request, Map.from_struct(lsp_request)) + {:ok, converted} + end + def to_elixir(request) do request = Map.merge(request, Map.from_struct(request.lsp)) @@ -32,19 +39,58 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert do :error end - defp convert(%{range: range}, source_file) do - with {:ok, ex_range} <- Conversions.to_elixir(range, source_file) do - {:ok, %{range: ex_range}} + defp convert(%_{text_document: _} = request) do + with {:ok, source_file} <- fetch_source_file(request), + {:ok, converted} <- convert(request, source_file) do + {:ok, converted, source_file} end end - defp convert(%{position: position}, source_file) do - with {:ok, ex_pos} <- Conversions.to_elixir(position, source_file) do - {:ok, %{position: ex_pos}} - end + defp convert(%_{} = request) do + {:ok, request, nil} + end + + defp convert(%Types.Range{} = range, %SourceFile{} = source_file) do + Conversions.to_elixir(range, source_file) + end + + defp convert(%Types.Position{} = pos, %SourceFile{} = source_file) do + Conversions.to_elixir(pos, source_file) + end + + defp convert(%_struct{} = request, %SourceFile{} = source_file) do + kvps = + request + |> Map.from_struct() + |> Enum.reduce(request, fn {key, value}, request -> + {:ok, value} = convert(value, source_file) + Map.put(request, key, value) + end) + + {:ok, Map.merge(request, kvps)} + end + + defp convert(list, %SourceFile{} = source_file) when is_list(list) do + items = + Enum.map(list, fn item -> + {:ok, item} = convert(item, source_file) + item + end) + + {:ok, items} + end + + defp convert(%{} = map, %SourceFile{} = source_file) do + converted = + Map.new(map, fn {k, v} -> + {:ok, converted} = convert(v, source_file) + {k, converted} + end) + + {:ok, converted} end - defp convert(_, _) do - {:ok, %{}} + defp convert(item, %SourceFile{} = _) do + {:ok, item} end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/macros/json.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/json.ex index 0a4ef5aac..1cb318324 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/macros/json.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/json.ex @@ -17,7 +17,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Json do |> Enum.flat_map(fn # flatten the spread into the current map {:.., value} when is_map(value) -> Enum.to_list(value) - {k, v} -> [{k, v}] + {k, v} -> [{camelize(k), v}] end) |> JasonVendored.Encode.keyword(opts) end @@ -29,6 +29,17 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Json do defp get_field_value(struct, field_name) do Map.get(struct, field_name) end + + def camelize(field_name) do + field_name + |> to_string() + |> Macro.camelize() + |> downcase_first() + end + + defp downcase_first(<>) do + String.downcase(c) <> rest + end end end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/requests.ex b/apps/language_server/lib/language_server/experimental/protocol/requests.ex index b2ba4264a..7094ea3e2 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/requests.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/requests.ex @@ -3,6 +3,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Requests do alias ElixirLS.LanguageServer.Experimental.Protocol.Proto alias ElixirLS.LanguageServer.Experimental.Protocol.Types + # Client -> Server request defmodule Initialize do use Proto @@ -12,36 +13,44 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Requests do locale: optional(string()), root_path: optional(string()), root_uri: string(), - initialization_options: optional(map_of(any())), + initialization_options: optional(any()), trace: optional(string()), - workspace_folders: optional(Types.WorkspaceFolder), + workspace_folders: optional(list_of(Types.WorkspaceFolder)), capabilities: optional(map_of(any())) end defmodule FindReferences do use Proto - defrequest("textDocument/references", :exclusive, + defrequest "textDocument/references", :exclusive, text_document: Types.TextDocument.Identifier, position: Types.Position - ) end defmodule Formatting do use Proto - defrequest("textDocument/formatting", :exclusive, + defrequest "textDocument/formatting", :exclusive, text_document: Types.TextDocument.Identifier, options: Types.FormattingOptions - ) end + defmodule CodeAction do + use Proto + + defrequest "textDocument/codeAction", :exclusive, + text_document: Types.TextDocument.Identifier, + range: Types.Range, + context: Types.CodeActionContext + end + + # Server -> Client requests + defmodule RegisterCapability do use Proto - defrequest("client/registerCapability", :shared, + defrequest "client/registerCapability", :shared, registrations: optional(list_of(LspTypes.Registration)) - ) end use Proto, decoders: :requests diff --git a/apps/language_server/lib/language_server/experimental/protocol/responses.ex b/apps/language_server/lib/language_server/experimental/protocol/responses.ex index 96ddc5038..b7133216e 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/responses.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/responses.ex @@ -13,4 +13,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Responses do defresponse optional(list_of(Types.TextEdit)) end + + defmodule CodeAction do + use Proto + + defresponse optional(list_of(Types.CodeAction)) + end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/types.ex b/apps/language_server/lib/language_server/experimental/protocol/types.ex index 5966bbf29..0d69715ea 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/types.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/types.ex @@ -36,12 +36,25 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Types do deftype uri: uri(), version: integer() end + defmodule TextDocument.OptionalVersionedIdentifier do + use Proto + + deftype uri: uri(), version: optional(integer()) + end + defmodule TextDocument.ContentChangeEvent do use Proto deftype range: optional(Range), text: string() end + defmodule TextDocument.Edit do + use Proto + + deftype text_document: TextDocument.OptionalVersionedIdentifier, + edits: list_of(TextEdit) + end + defmodule CodeDescription do use Proto @@ -147,6 +160,13 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Types do resource_operations: optional(list_of(ResourceOperationKind)) end + defmodule WorkspaceEdit do + use Proto + + deftype document_changes: optional(list_of(TextDocument.Edit)), + changes: optional(map_of(list_of(TextEdit))) + end + defmodule DidChangeConfiguration.ClientCapabilities do use Proto @@ -436,4 +456,53 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Types do use Proto deftype uri: uri(), name: string() end + + defmodule Command do + use Proto + + deftype title: string(), + command: string(), + arguments: optional(list_of(any())) + end + + defmodule CodeActionKind do + use Proto + + defenum empty: "", + quick_fix: "quickfix", + refactor: "refactor", + refactor_extract: "refactor.extract", + refactor_inline: "refactor.inline", + refactor_rewrite: "refactor.rewrite", + source: "source", + source_organize_imports: "source.organizeImports", + source_fix_all: "source.fixAll" + end + + defmodule CodeActionTriggerKind do + use Proto + + defenum invoked: 1, + automatic: 2 + end + + defmodule CodeActionContext do + use Proto + + deftype diagnostics: list_of(Diagnostic), + only: optional(list_of(CodeActionKind)), + trigger_kind: optional(CodeActionTriggerKind) + end + + defmodule CodeAction do + use Proto + + deftype title: string(), + kind: optional(CodeActionKind), + diagnostics: optional(list_of(Diagnostic)), + is_preferred: optional(boolean()), + edit: optional(WorkspaceEdit), + command: optional(Command), + data: optional(any()) + end end diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/remove_unused_variable.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/remove_unused_variable.ex new file mode 100644 index 000000000..e69de29bb diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex new file mode 100644 index 000000000..fd67473b5 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex @@ -0,0 +1,125 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore do + @moduledoc """ + A code action that prefixes unused variables with an underscore + """ + alias ElixirLS.LanguageServer.Experimental.Format.Diff + 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.Position + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.WorkspaceEdit + alias ElixirLS.LanguageServer.Experimental.SourceFile + + @spec apply(CodeAction.t()) :: [CodeActionReply.t()] + def apply(%CodeAction{} = code_action) do + source_file = code_action.source_file + diagnostics = code_action.context.diagnostics + + Enum.reduce(diagnostics, [], fn %Diagnostic{} = diagnostic, actions -> + with {:ok, variable_name, one_based_line} <- extract_variable_and_line(diagnostic), + {:ok, reply} <- build_code_action(source_file, one_based_line, variable_name) do + [reply | actions] + else + _ -> + actions + end + end) + |> Enum.reverse() + end + + defp build_code_action(%SourceFile{} = source_file, one_based_line, variable_name) do + with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line), + {:ok, line_ast} <- ElixirSense.string_to_quoted(line_text, 0), + {:ok, transformed} <- apply_transform(line_ast, variable_name) do + text_edits = + line_text + |> to_text_edits(transformed) + |> update_lines(one_based_line) + |> Enum.filter(fn edit -> edit.new_text == "_" end) + + reply = + CodeActionResult.new( + title: "Rename to _#{variable_name}", + kind: :quick_fix, + edit: WorkspaceEdit.new(changes: %{source_file.uri => text_edits}) + ) + + {:ok, reply} + end + end + + defp to_text_edits(orig_text, fixed_text) do + orig_text + |> Diff.diff(fixed_text) + |> Enum.filter(fn edit -> edit.new_text == "_" end) + end + + defp update_lines(text_edits, one_based_line) do + Enum.map(text_edits, fn %TextEdit{} = text_edit -> + start_line = text_edit.range.start.line + one_based_line - 1 + end_line = text_edit.range.end.line + one_based_line - 1 + + %TextEdit{ + text_edit + | range: %Range{ + start: %Position{text_edit.range.start | line: start_line}, + end: %Position{text_edit.range.end | line: end_line} + } + } + end) + end + + defp apply_transform(quoted_ast, unused_variable_name) do + underscored_variable_name = :"_#{unused_variable_name}" + + Macro.postwalk(quoted_ast, fn + {^unused_variable_name, meta, context} -> + {underscored_variable_name, meta, context} + + other -> + other + end) + |> Macro.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 + |> fetch_line(0) + end + + defp extract_variable_and_line(%Diagnostic{} = diagnostic) do + with {:ok, variable_name} <- extract_variable_name(diagnostic.message), + {:ok, line} <- extract_line(diagnostic) do + {:ok, variable_name, 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 extract_line(%Diagnostic{} = diagnostic) do + {:ok, diagnostic.range.start.line} + 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 diff --git a/apps/language_server/lib/language_server/experimental/provider/env.ex b/apps/language_server/lib/language_server/experimental/provider/env.ex index adcf513b1..1bdde12cf 100644 --- a/apps/language_server/lib/language_server/experimental/provider/env.ex +++ b/apps/language_server/lib/language_server/experimental/provider/env.ex @@ -1,7 +1,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.Env do + @moduledoc """ + An environment passed to provider handlers. + This represents the current state of the project, and should include additional + information that provider handles might need to complete their tasks. + """ + alias ElixirLS.LanguageServer.Experimental.Project alias ElixirLS.LanguageServer.Experimental.Server.Configuration - alias ElixirLS.LanguageServer.SourceFile defstruct [:root_uri, :root_path, :project_uri, :project_path] 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 new file mode 100644 index 000000000..c915eb10a --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/handlers/code_action.ex @@ -0,0 +1,16 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do + 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 + + def handle(%Requests.CodeAction{} = request, %Env{}) do + code_actions = ReplaceWithUnderscore.apply(request) + reply = Responses.CodeAction.new(request.id, code_actions) + + {:reply, reply} + end +end diff --git a/apps/language_server/lib/language_server/experimental/provider/queue.ex b/apps/language_server/lib/language_server/experimental/provider/queue.ex index e514d142f..834594b93 100644 --- a/apps/language_server/lib/language_server/experimental/provider/queue.ex +++ b/apps/language_server/lib/language_server/experimental/provider/queue.ex @@ -14,7 +14,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.Queue do @requests_to_handler %{ Requests.FindReferences => Handlers.FindReferences, - Requests.Formatting => Handlers.Formatting + Requests.Formatting => Handlers.Formatting, + Requests.CodeAction => Handlers.CodeAction } def new do diff --git a/apps/language_server/lib/language_server/experimental/server/configuration.ex b/apps/language_server/lib/language_server/experimental/server/configuration.ex index 384a7ee35..11931fce3 100644 --- a/apps/language_server/lib/language_server/experimental/server/configuration.ex +++ b/apps/language_server/lib/language_server/experimental/server/configuration.ex @@ -8,8 +8,6 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.Configuration do alias ElixirLS.LanguageServer.Experimental.Protocol.Requests alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.RegisterCapability alias ElixirLS.LanguageServer.Experimental.Server.Configuration.Support - alias ElixirLS.LanguageServer.SourceFile - alias ElixirLS.Utils.MixfileHelpers defstruct project: nil, support: nil, diff --git a/apps/language_server/lib/language_server/experimental/source_file.ex b/apps/language_server/lib/language_server/experimental/source_file.ex index 2249cac32..f9d3e32ff 100644 --- a/apps/language_server/lib/language_server/experimental/source_file.ex +++ b/apps/language_server/lib/language_server/experimental/source_file.ex @@ -186,7 +186,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do end defp apply_valid_edits(%__MODULE{} = source, edit_text, start_pos, end_pos) do - Enum.reduce(source.document, [], fn line() = line, acc -> + Document.reduce(source.document, [], fn line() = line, acc -> case edit_action(line, edit_text, start_pos, end_pos) do :drop -> acc diff --git a/apps/language_server/lib/language_server/experimental/source_file/document.ex b/apps/language_server/lib/language_server/experimental/source_file/document.ex index 6060de5d2..0a3538c93 100644 --- a/apps/language_server/lib/language_server/experimental/source_file/document.ex +++ b/apps/language_server/lib/language_server/experimental/source_file/document.ex @@ -17,7 +17,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Document do end def to_iodata(%__MODULE__{} = document) do - Enum.reduce(document, [], fn line(text: text, ending: ending), acc -> + reduce(document, [], fn line(text: text, ending: ending), acc -> [acc | [text | ending]] end) end @@ -32,12 +32,31 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Document do tuple_size(document.lines) end + def fetch_line(%__MODULE__{lines: lines, starting_index: starting_index}, index) + when index - starting_index >= tuple_size(lines) do + :error + end + def fetch_line(%__MODULE__{} = document, index) when is_integer(index) do - case Enum.at(document, index - document.starting_index) do + case elem(document.lines, index - document.starting_index) do line() = line -> {:ok, line} _ -> :error end end + + def reduce(%__MODULE__{} = document, initial, reducer_fn) do + size = size(document) + + if size == 0 do + initial + else + Enum.reduce(0..(size - 1), initial, fn index, acc -> + document.lines + |> elem(index) + |> reducer_fn.(acc) + end) + end + end end defimpl Enumerable, for: ElixirLS.LanguageServer.Experimental.SourceFile.Document do diff --git a/apps/language_server/test/experimental/protocol/proto_test.exs b/apps/language_server/test/experimental/protocol/proto_test.exs index 0e64541cc..2d34e8bd2 100644 --- a/apps/language_server/test/experimental/protocol/proto_test.exs +++ b/apps/language_server/test/experimental/protocol/proto_test.exs @@ -525,7 +525,8 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do i: integer(), lit: literal("foo"), enum: Mood, - c: optional(Child) + c: optional(Child), + snake_case_name: string() end def fixture(:encoding, include_child \\ false) do @@ -535,7 +536,8 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do "l" => ~w(these are strings), "i" => 42, "enum" => 1, - "lit" => "foo" + "lit" => "foo", + "snakeCaseName" => "foo" } if include_child do @@ -557,6 +559,14 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do assert {:ok, decoded} = encode_and_decode(proto) assert decoded == expected end + + test "it camelizes encoded field names" do + expected = fixture(:encoding) + assert {:ok, proto} = EncodingTest.parse(expected) + assert proto.snake_case_name == "foo" + assert {:ok, decoded} = encode_and_decode(proto) + assert decoded["snakeCaseName"] == "foo" + end end describe "spread" do diff --git a/apps/language_server/test/experimental/provider/code_action/#warning_parser.ex# b/apps/language_server/test/experimental/provider/code_action/#warning_parser.ex# new file mode 100644 index 000000000..1081b95ec --- /dev/null +++ b/apps/language_server/test/experimental/provider/code_action/#warning_parser.ex# @@ -0,0 +1,59 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.WarningParser do + @moduledoc """ + A parser for elixir warning messages + """ + + @type error_message :: String.t() + + def parse(warning_or_error) do + with {:ok, error_type} <- extract_type(warning_or_error), + {:ok, message} <- extract_message(warning_or_error), + {:ok, path, line} <- extract_path_and_line(warning_or_error), + {:ok, mfa} <- extract_mfa(warning_or_error) do + {:ok, error_type, message, path, line, mfa} + end + end + + defp extract_type(warning_or_error) do + + end + + @path_and_line_re ~r/\s+([^\:]+):(\d+)/ + def extract_path_and_line(message) do + with {:ok, line} <- fetch_line(message, 1), + [[_, path, line_number_string]] <- Regex.scan(@path_and_line_re, line), + {line_number, ""} <- Integer.parse(line_number_string) do + {:ok, path, line_number} + else + _ -> + :error + end + end + + @mfa_re ~r/:\s+([^\/]+)\/(\d+)/ + defp extract_mfa(message) do + with {:ok, line} <- fetch_line(message, 1), + [[_, module_and_function, arity_string]] <- Regex.scan(@mfa_re, line), + {arity, ""} <- Integer.parse(arity_string) do + {module, function} = split_module_and_function(module_and_function) + {:ok, {module, function, arity}} + else + _ -> + :error + end + end + + defp split_module_and_function(module_and_function) do + [function | reversed_module] = + module_and_function + |> String.split(".") + |> Enum.reverse() + + module = + reversed_module + |> Enum.reduce([], fn piece, acc -> [String.to_atom(piece) | acc] end) + |> Module.concat() + + {module, String.to_atom(function)} + end +end diff --git a/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs new file mode 100644 index 000000000..328db501d --- /dev/null +++ b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs @@ -0,0 +1,278 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscoreTest do + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction + + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction, as: CodeActionReply + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeActionContext + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Diagnostic + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextDocument.ContentChangeEvent + + alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Fixtures.LspProtocol + alias ElixirLS.LanguageServer.SourceFile.Path, as: SourceFilePath + + import LspProtocol + import ReplaceWithUnderscore + + use ExUnit.Case + use Patch + + def diagnostic_message(file_path, line, variable_name, {module_name, function_name, arity}) do + """ + warning: variable "#{variable_name}" is unused (if the variable is not meant to be used, prefix it with an underscore) + #{file_path}:#{line}: #{module_name}.#{function_name}/#{arity} + """ + end + + def code_action(file_body, file_path, line, variable_name, opts \\ []) do + trimmed_body = String.trim(file_body) + file_uri = SourceFilePath.to_uri(file_path) + patch(SourceFile.Store, :fetch, {:ok, SourceFile.new(file_uri, trimmed_body, 1)}) + + {:ok, range} = + build(Range, + start: [line: line, character: 0], + end: [line: line, character: 0] + ) + + message_file_path = Keyword.get(opts, :message_file_path, file_path) + mfa = Keyword.get(opts, :mfa, {"MyModule", "myfunc", 1}) + + message = diagnostic_message(message_file_path, line, variable_name, mfa) + diagnostic = Diagnostic.new(range: range, message: message) + {:ok, context} = build(CodeActionContext, diagnostics: [diagnostic]) + + {:ok, action} = + build(CodeAction, + text_document: [uri: file_uri], + range: range, + context: context + ) + + {:ok, action} = Requests.to_elixir(action) + {file_uri, trimmed_body, action} + end + + def to_map(%Range{} = range) do + range + |> JasonVendored.encode!() + |> JasonVendored.decode!() + end + + def apply_edit(source, edits) do + source_file = SourceFile.new("file:///none", source, 1) + + converted_edits = + Enum.map(edits, fn edit -> + ContentChangeEvent.new(text: edit.new_text, range: edit.range) + end) + + {:ok, source} = SourceFile.apply_content_changes(source_file, 3, converted_edits) + + source + |> SourceFile.to_string() + |> String.trim() + end + + test "produces no actions if the line is empty" do + {_, _, action} = code_action("", "/project/file.ex", 1, "a") + assert [] = apply(action) + end + + describe "fixes in parameters" do + test "applied to an unadorned param" do + {file_uri, source, code_action} = + ~S[ + def my_func(a) do + ] + |> code_action("/project/file.ex", 0, "a") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "def my_func(_a) do" == apply_edit(source, edit) + end + + test "applied to a pattern match in params" do + {file_uri, source, code_action} = + ~S[ + def my_func(%SourceFile{} = unused) do + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "def my_func(%SourceFile{} = _unused) do" = apply_edit(source, edit) + end + + test "applied to a pattern match preceding a struct in params" do + {file_uri, source, code_action} = + ~S[ + def my_func(unused = %SourceFile{}) do + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "def my_func(_unused = %SourceFile{}) do" = apply_edit(source, edit) + end + + test "applied prior to a map" do + {file_uri, source, code_action} = + ~S[ + def my_func(unused = %{}) do + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "def my_func(_unused = %{}) do" = apply_edit(source, edit) + end + + test "applied after a map %{} = unused" do + {file_uri, source, code_action} = + ~S[ + def my_func(%{} = unused) do + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "def my_func(%{} = _unused) do" = apply_edit(source, edit) + end + + test "applied to a map key %{foo: unused}" do + {file_uri, source, code_action} = + ~S[ + def my_func(%{foo: unused}) do + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "def my_func(%{foo: _unused}) do" = apply_edit(source, edit) + end + + test "applied to a list element params = [unused, a, b | rest]" do + {file_uri, source, code_action} = + ~S{ + def my_func([unused, a, b | rest]) do + } + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "def my_func([_unused, a, b | rest]) do" = apply_edit(source, edit) + end + + test "applied to the tail of a list params = [a, b, | unused]" do + {file_uri, source, code_action} = + ~S{ + def my_func([a, b | unused]) do + } + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "def my_func([a, b | _unused]) do" = apply_edit(source, edit) + end + end + + describe "fixes in variables" do + test "applied to a variable match " do + {file_uri, source, code_action} = + ~S[ + x = 3 + ] + |> code_action("/project/file.ex", 0, "x", mfa: {"iex", "nofunction", 0}) + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + + assert "_x = 3" == apply_edit(source, edit) + end + + test "applied to a variable with a pattern matched struct" do + {file_uri, source, code_action} = + ~S[ + unused = %Struct{} + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "_unused = %Struct{}" = apply_edit(source, edit) + end + + test "applied to struct param matches" do + {file_uri, source, code_action} = + ~S[ + %Struct{field: unused, other_field: used} + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "%Struct{field: _unused, other_field: used}" = apply_edit(source, edit) + end + + test "applied to a struct module match %module{}" do + {file_uri, source, code_action} = + ~S[ + %unused{field: first, other_field: used} + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "%_unused{field: first, other_field: used}" = apply_edit(source, edit) + end + + test "applied to a tuple value" do + {file_uri, source, code_action} = + ~S[ + {a, b, unused, c} = whatever + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "{a, b, _unused, c} = whatever" = apply_edit(source, edit) + end + + test "applied to a list element" do + {file_uri, source, code_action} = + ~S{ + [a, b, unused, c] = whatever + } + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "[a, b, _unused, c] = whatever" = apply_edit(source, edit) + end + + test "applied to map value" do + {file_uri, source, code_action} = + ~S[ + %{foo: a, bar: unused} = whatever + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "%{foo: a, bar: _unused} = whatever" = apply_edit(source, edit) + end + end + + describe "fixes in structures" do + test "applied to a match of a comprehension" do + {file_uri, source, code_action} = + "for {unused, something_else} <- my_enum, do: something_else" + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + + assert "for {_unused, something_else} <- my_enum, do: something_else" == + apply_edit(source, edit) + end + + test "applied to a match in a with block" do + {file_uri, source, code_action} = + "with {unused, something_else} <- my_enum, do: something_else" + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + + expected = "with {_unused, something_else} <- my_enum, do: something_else" + + assert String.trim(expected) == apply_edit(source, edit) + end + end +end diff --git a/apps/language_server/test/experimental/provider/handlers/code_action.ex b/apps/language_server/test/experimental/provider/handlers/code_action.ex new file mode 100644 index 000000000..771e74920 --- /dev/null +++ b/apps/language_server/test/experimental/provider/handlers/code_action.ex @@ -0,0 +1,13 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do + alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore + alias ElixirLS.LanguageServer.Experimental.Provider.Env + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction + ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore + + def handle(%CodeAction{} = request, %Env{}) do + case ReplaceWithUnderscore.apply(reqest) do + [] -> + nil + end + end +end From fa469a32f9ef7d2abe58c0f7917e583b16e3b3e7 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Mon, 21 Nov 2022 19:08:55 -0800 Subject: [PATCH 03/21] Notifications can be sent from the server --- .../experimental/protocol/notifications.ex | 24 ++++++++++++------- .../protocol/proto/notification.ex | 18 ++++++++++++++ .../code_action/remove_unused_variable.ex | 0 3 files changed, 34 insertions(+), 8 deletions(-) delete mode 100644 apps/language_server/lib/language_server/experimental/provider/code_action/remove_unused_variable.ex diff --git a/apps/language_server/lib/language_server/experimental/protocol/notifications.ex b/apps/language_server/lib/language_server/experimental/protocol/notifications.ex index 8a42fa7c4..c4604a71d 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/notifications.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/notifications.ex @@ -10,46 +10,54 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Notifications do defmodule Cancel do use Proto - defnotification("$/cancelRequest", :shared, id: integer()) + defnotification "$/cancelRequest", :shared, id: integer() end defmodule DidOpen do use Proto - defnotification("textDocument/didOpen", :shared, text_document: Types.TextDocument) + defnotification "textDocument/didOpen", :shared, text_document: Types.TextDocument end defmodule DidClose do use Proto - defnotification("textDocument/didClose", :shared, text_document: Types.TextDocument.Identifier) + defnotification "textDocument/didClose", :shared, text_document: Types.TextDocument.Identifier end defmodule DidChange do use Proto - defnotification("textDocument/didChange", :shared, + defnotification "textDocument/didChange", :shared, text_document: Types.TextDocument.VersionedIdentifier, content_changes: list_of(Types.TextDocument.ContentChangeEvent) - ) end defmodule DidChangeConfiguration do use Proto - defnotification("workspace/didChangeConfiguration", :shared, settings: map_of(any())) + defnotification "workspace/didChangeConfiguration", :shared, settings: map_of(any()) end defmodule DidChangeWatchedFiles do use Proto - defnotification("workspace/didChangeWatchedFiles", :shared, changes: list_of(Types.FileEvent)) + defnotification "workspace/didChangeWatchedFiles", :shared, changes: list_of(Types.FileEvent) end defmodule DidSave do use Proto - defnotification("textDocument/didSave", :shared, text_document: Types.TextDocument.Identifier) + defnotification "textDocument/didSave", :shared, text_document: Types.TextDocument.Identifier + end + + defmodule PublishDiagnostics do + use Proto + + defnotification "textDocument/publishDiagnostics", :shared, + uri: string(), + version: optional(integer()), + diagnostics: list_of(Types.Diagnostic) end use Proto, decoders: :notifications diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex index 3d9653bec..7dbf78424 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex @@ -13,6 +13,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Notification do param_names = Keyword.keys(types) lsp_types = Keyword.merge(jsonrpc_types, types) elixir_types = Message.generate_elixir_types(__CALLER__.module, lsp_types) + lsp_module_name = Module.concat(__CALLER__.module, LSP) quote location: :keep do defmodule LSP do @@ -36,6 +37,23 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Notification do def to_elixir(%__MODULE__{} = request) do Convert.to_elixir(request) end + + defimpl JasonVendored.Encoder, for: unquote(__CALLER__.module) do + def encode(notification, opts) do + JasonVendored.Encoder.encode(notification.lsp, opts) + end + end + + defimpl JasonVendored.Encoder, for: unquote(lsp_module_name) do + def encode(notification, opts) do + %{ + jsonrpc: "2.0", + method: unquote(method), + params: Map.take(notification, unquote(param_names)) + } + |> JasonVendored.Encode.map(opts) + end + end end end diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/remove_unused_variable.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/remove_unused_variable.ex deleted file mode 100644 index e69de29bb..000000000 From 4fb806855ce51f6634f57b10cb41c9ba8b669904 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Tue, 22 Nov 2022 14:58:34 -0800 Subject: [PATCH 04/21] Properly handled spacing --- .../code_action/replace_with_underscore.ex | 23 +++++++++++++--- .../replace_with_underscore_test.exs | 27 ++++++++++++++----- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex index fd67473b5..acbe36af3 100644 --- a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex @@ -32,12 +32,11 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn defp build_code_action(%SourceFile{} = source_file, one_based_line, variable_name) do with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line), {:ok, line_ast} <- ElixirSense.string_to_quoted(line_text, 0), - {:ok, transformed} <- apply_transform(line_ast, variable_name) do + {:ok, transformed} <- apply_transform(line_text, line_ast, variable_name) do text_edits = line_text |> to_text_edits(transformed) |> update_lines(one_based_line) - |> Enum.filter(fn edit -> edit.new_text == "_" end) reply = CodeActionResult.new( @@ -53,7 +52,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn defp to_text_edits(orig_text, fixed_text) do orig_text |> Diff.diff(fixed_text) - |> Enum.filter(fn edit -> edit.new_text == "_" end) + |> Enum.filter(&String.contains?(&1.new_text, "_")) end defp update_lines(text_edits, one_based_line) do @@ -71,8 +70,9 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn end) end - defp apply_transform(quoted_ast, unused_variable_name) do + defp apply_transform(line_text, quoted_ast, unused_variable_name) do underscored_variable_name = :"_#{unused_variable_name}" + leading_indent = leading_indent(line_text) Macro.postwalk(quoted_ast, fn {^unused_variable_name, meta, context} -> @@ -87,6 +87,21 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn # adds additional lines do documents with errors, so take the first line, as it's # the properly transformed source |> fetch_line(0) + |> case do + {:ok, text} -> + {:ok, "#{leading_indent}#{text}"} + + error -> + 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 extract_variable_and_line(%Diagnostic{} = diagnostic) do diff --git a/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs index 328db501d..73d221a4d 100644 --- a/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs +++ b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs @@ -1,13 +1,11 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscoreTest do alias ElixirLS.LanguageServer.Experimental.Protocol.Requests alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction - alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction, as: CodeActionReply alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeActionContext alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Diagnostic alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextDocument.ContentChangeEvent - alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore alias ElixirLS.LanguageServer.Experimental.SourceFile alias ElixirLS.LanguageServer.Fixtures.LspProtocol @@ -27,7 +25,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn end def code_action(file_body, file_path, line, variable_name, opts \\ []) do - trimmed_body = String.trim(file_body) + trimmed_body = String.trim(file_body, "\n") + file_uri = SourceFilePath.to_uri(file_path) patch(SourceFile.Store, :fetch, {:ok, SourceFile.new(file_uri, trimmed_body, 1)}) @@ -61,7 +60,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn |> JasonVendored.decode!() end - def apply_edit(source, edits) do + def apply_edit(source, edits, opts \\ []) do source_file = SourceFile.new("file:///none", source, 1) converted_edits = @@ -71,9 +70,13 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn {:ok, source} = SourceFile.apply_content_changes(source_file, 3, converted_edits) - source - |> SourceFile.to_string() - |> String.trim() + if Keyword.get(opts, :trim, true) do + source + |> SourceFile.to_string() + |> String.trim() + else + SourceFile.to_string(source) + end end test "produces no actions if the line is empty" do @@ -184,6 +187,16 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn assert "_x = 3" == apply_edit(source, edit) end + test "preserves spacing" do + {file_uri, source, code_action} = + " x = 3" + |> code_action("/project/file.ex", 0, "x", mfa: {"iex", "nofunction", 1}) + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + + assert " _x = 3" == apply_edit(source, edit, trim: false) + end + test "applied to a variable with a pattern matched struct" do {file_uri, source, code_action} = ~S[ From 8fecb836b284ccfe76264a89fa61b027f044e625 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Tue, 22 Nov 2022 15:32:57 -0800 Subject: [PATCH 05/21] Enforced required keys for jsonrpc messages --- .../protocol/proto/macros/struct.ex | 18 ++++++++++++++- .../protocol/proto/notification.ex | 12 +++++++++- .../experimental/protocol/proto/request.ex | 12 +++++++++- .../experimental/protocol/proto/response.ex | 2 +- .../test/experimental/protocol/proto_test.exs | 22 ++++++++++++++++++- 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/macros/struct.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/struct.ex index cc770dba7..2fece5328 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/macros/struct.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/struct.ex @@ -1,6 +1,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Struct do def build(opts) do keys = Keyword.keys(opts) + required_keys = required_keys(opts) keys = if :.. in keys do @@ -20,13 +21,28 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Struct do end quote location: :keep do + @enforce_keys unquote(required_keys) defstruct unquote(keys) def new(opts \\ []) do - struct(__MODULE__, opts) + struct!(__MODULE__, opts) end defoverridable new: 0, new: 1 end end + + defp required_keys(opts) do + Enum.filter(opts, fn + # ignore the splat, it's always optional + {:.., _} -> false + # an optional signifier tuple + {_, {:optional, _}} -> false + # ast for an optional signifier tuple + {_, {:optional, _, _}} -> false + # everything else is required + _ -> true + end) + |> Keyword.keys() + end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex index 7dbf78424..962e533ab 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex @@ -18,6 +18,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Notification do quote location: :keep do defmodule LSP do unquote(Message.build({:notification, :lsp}, method, access, lsp_types, param_names)) + + def new(opts \\ []) do + opts + |> Keyword.merge(method: unquote(method), jsonrpc: "2.0") + |> super() + end end alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert @@ -31,7 +37,11 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Notification do unquote(build_parse(method)) def new(opts \\ []) do - %__MODULE__{lsp: LSP.new(opts), method: unquote(method)} + opts = Keyword.merge(opts, method: unquote(method), jsonrpc: "2.0") + + # use struct here because initially, the non-lsp struct doesn't have + # to be filled out. Calling to_elixir fills it out. + struct(__MODULE__, lsp: LSP.new(opts), method: unquote(method), jsonrpc: "2.0") end def to_elixir(%__MODULE__{} = request) do diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex index 13292b2b5..9d7da0816 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex @@ -24,6 +24,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Request do quote location: :keep do defmodule LSP do unquote(Message.build({:request, :lsp}, method, access, lsp_types, param_names)) + + def new(opts \\ []) do + opts + |> Keyword.merge(method: unquote(method), jsonrpc: "2.0") + |> super() + end end alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert @@ -38,8 +44,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Request do unquote(build_parse(method)) def new(opts \\ []) do + opts = Keyword.merge(opts, method: unquote(method), jsonrpc: "2.0") + raw = LSP.new(opts) - %__MODULE__{lsp: raw, id: raw.id, method: unquote(method)} + # use struct here because initially, the non-lsp struct doesn't have + # to be filled out. Calling to_elixir fills it out. + struct(__MODULE__, lsp: raw, id: raw.id, method: unquote(method), jsonrpc: "2.0") end def to_elixir(%__MODULE__{} = request) do diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/response.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/response.ex index 5726da650..98f5efb7d 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/response.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/response.ex @@ -15,7 +15,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Response do jsonrpc_types = [ id: quote(do: optional(one_of([integer(), string()]))), error: quote(do: optional(LspTypes.ResponseError)), - result: response_type + result: quote(do: optional(unquote(response_type))) ] quote location: :keep do diff --git a/apps/language_server/test/experimental/protocol/proto_test.exs b/apps/language_server/test/experimental/protocol/proto_test.exs index 2d34e8bd2..9be78be45 100644 --- a/apps/language_server/test/experimental/protocol/proto_test.exs +++ b/apps/language_server/test/experimental/protocol/proto_test.exs @@ -227,6 +227,26 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do end end + describe "constructors" do + defmodule RequiredFields do + use Proto + + deftype name: string(), value: optional(string()), age: integer() + end + + test "required fields are required" do + assert_raise ArgumentError, fn -> + RequiredFields.new() + end + + assert_raise ArgumentError, fn -> + RequiredFields.new(name: "hi", value: "good") + end + + assert RequiredFields.new(name: "hi", value: "good", age: 29) + end + end + def with_source_file_store(_) do source_file = """ defmodule MyTest do @@ -595,7 +615,7 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do describe "access behavior" do defmodule Recursive do use Proto - deftype name: string(), age: integer(), child: __MODULE__ + deftype name: string(), age: integer(), child: optional(__MODULE__) end def family do From 2145c247711893b8caf04d119c3219de8c0e4029 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Tue, 22 Nov 2022 16:19:05 -0800 Subject: [PATCH 06/21] removed unused variable --- apps/language_server/lib/language_server/server.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index dc7dd44c2..9636062f5 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -543,7 +543,7 @@ defmodule ElixirLS.LanguageServer.Server do end defp handle_request( - initialize_req(_id, root_uri, client_capabilities) = request, + initialize_req(_id, root_uri, client_capabilities), state = %__MODULE__{server_instance_id: server_instance_id} ) when not is_initialized(server_instance_id) do From e4b7ee47dc02fb41ea199857e1df2b1fb900ddf8 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Wed, 23 Nov 2022 11:05:00 -0800 Subject: [PATCH 07/21] Committed to pipeline --- .../provider/code_action/replace_with_underscore.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex index acbe36af3..d48e45db1 100644 --- a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex @@ -17,7 +17,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn source_file = code_action.source_file diagnostics = code_action.context.diagnostics - Enum.reduce(diagnostics, [], fn %Diagnostic{} = diagnostic, actions -> + diagnostics + |> Enum.reduce([], fn %Diagnostic{} = diagnostic, actions -> with {:ok, variable_name, one_based_line} <- extract_variable_and_line(diagnostic), {:ok, reply} <- build_code_action(source_file, one_based_line, variable_name) do [reply | actions] From d46d34287b3cfb72225c56f7bf095dca9eb6423c Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Tue, 29 Nov 2022 08:42:10 -0800 Subject: [PATCH 08/21] Added tests that check to ensure comments are preserved --- .../code_action/replace_with_underscore.ex | 3 ++- .../replace_with_underscore_test.exs | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex index d48e45db1..cb55ff60d 100644 --- a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex @@ -32,7 +32,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn defp build_code_action(%SourceFile{} = source_file, one_based_line, variable_name) do with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line), - {:ok, line_ast} <- ElixirSense.string_to_quoted(line_text, 0), + {:ok, line_ast} <- + ElixirSense.string_to_quoted(line_text, 1, 6), {:ok, transformed} <- apply_transform(line_text, line_ast, variable_name) do text_edits = line_text diff --git a/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs index 73d221a4d..41c8a7951 100644 --- a/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs +++ b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs @@ -187,6 +187,17 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn assert "_x = 3" == apply_edit(source, edit) end + test "applied to a variable match, preserves comments" do + {file_uri, source, code_action} = + ~S[ + a = bar # TODO: Fix this + ] + |> code_action("/project/file.ex", 0, "a") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "_a = bar # TODO: Fix this" == apply_edit(source, edit) + end + test "preserves spacing" do {file_uri, source, code_action} = " x = 3" @@ -208,6 +219,17 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn assert "_unused = %Struct{}" = apply_edit(source, edit) end + test "applied to a variable with a pattern matched struct preserves trailing comments" do + {file_uri, source, code_action} = + ~S[ + unused = %Struct{} # TODO: fix + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert "_unused = %Struct{} # TODO: fix" = apply_edit(source, edit) + end + test "applied to struct param matches" do {file_uri, source, code_action} = ~S[ From 77f415e140500fc77c5e7761f81c658114e83c82 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Tue, 29 Nov 2022 10:00:31 -0800 Subject: [PATCH 09/21] Code modification framework First attempt at a standard interface for code modification. Code mod modules take the original text, the ast of the original text and arguments that they specify. They return a list of code edits or an error. --- .../experimental/code_mod/ast.ex | 18 ++ .../experimental/{format => code_mod}/diff.ex | 12 +- .../experimental/{ => code_mod}/format.ex | 4 +- .../code_mod/replace_with_underscore.ex | 74 +++++ .../code_action/replace_with_underscore.ex | 115 ++----- .../provider/handlers/formatting.ex | 2 +- .../experimental/source_file.ex | 5 + .../{format => code_mod}/diff_test.exs | 116 +++++++- .../experimental/code_mod/format_test.exs | 88 ++++++ .../code_mod/replace_with_underscore_test.exs | 214 +++++++++++++ .../test/experimental/formatter_test.exs | 74 ----- .../replace_with_underscore_test.exs | 281 ++++-------------- .../experimental/code_mod/code_mod_case.ex | 92 ++++++ 13 files changed, 681 insertions(+), 414 deletions(-) create mode 100644 apps/language_server/lib/language_server/experimental/code_mod/ast.ex rename apps/language_server/lib/language_server/experimental/{format => code_mod}/diff.ex (83%) rename apps/language_server/lib/language_server/experimental/{ => code_mod}/format.ex (97%) create mode 100644 apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex rename apps/language_server/test/experimental/{format => code_mod}/diff_test.exs (52%) create mode 100644 apps/language_server/test/experimental/code_mod/format_test.exs create mode 100644 apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs delete mode 100644 apps/language_server/test/experimental/formatter_test.exs create mode 100644 apps/language_server/test/support/experimental/code_mod/code_mod_case.ex diff --git a/apps/language_server/lib/language_server/experimental/code_mod/ast.ex b/apps/language_server/lib/language_server/experimental/code_mod/ast.ex new file mode 100644 index 000000000..0fd0df006 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/code_mod/ast.ex @@ -0,0 +1,18 @@ +defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Ast do + alias ElixirLS.LanguageServer.Experimental.SourceFile + @opaque t :: tuple() + + def from(%SourceFile{} = source_file) do + source_file + |> SourceFile.to_string() + |> from() + end + + def from(s) when is_binary(s) do + parse(s) + end + + defp parse(s) when is_binary(s) do + ElixirSense.string_to_quoted(s, 1, 6, token_metadata: true) + end +end diff --git a/apps/language_server/lib/language_server/experimental/format/diff.ex b/apps/language_server/lib/language_server/experimental/code_mod/diff.ex similarity index 83% rename from apps/language_server/lib/language_server/experimental/format/diff.ex rename to apps/language_server/lib/language_server/experimental/code_mod/diff.ex index 2e28659f7..2c28fd94f 100644 --- a/apps/language_server/lib/language_server/experimental/format/diff.ex +++ b/apps/language_server/lib/language_server/experimental/code_mod/diff.ex @@ -1,4 +1,4 @@ -defmodule ElixirLS.LanguageServer.Experimental.Format.Diff do +defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Diff do alias ElixirLS.LanguageServer.Experimental.CodeUnit alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Position alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range @@ -17,7 +17,15 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.Diff do apply_diff(diff_type, position, diff_string, edits) end) - Enum.reduce(edits, [], &collapse/2) + edits + |> Enum.reduce([], &collapse/2) + + # Sorting in reverse by start character and line ensures edits are applied back to front on + # throughout a document which means deletes won't affect subsequent edits and mess up their + # start / end ranges + # TODO: This would be more easily accomplished by adding edits to a list for each line + # and then flat_mapping the result + |> Enum.sort_by(fn edit -> {edit.range.start.line, edit.range.start.character} end, :desc) end # This collapses a delete and an an insert that are adjacent to one another diff --git a/apps/language_server/lib/language_server/experimental/format.ex b/apps/language_server/lib/language_server/experimental/code_mod/format.ex similarity index 97% rename from apps/language_server/lib/language_server/experimental/format.ex rename to apps/language_server/lib/language_server/experimental/code_mod/format.ex index 64b749fc8..4fc0ef6db 100644 --- a/apps/language_server/lib/language_server/experimental/format.ex +++ b/apps/language_server/lib/language_server/experimental/code_mod/format.ex @@ -1,5 +1,5 @@ -defmodule ElixirLS.LanguageServer.Experimental.Format do - alias ElixirLS.LanguageServer.Experimental.Format.Diff +defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Format do + alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff alias ElixirLS.LanguageServer.Experimental.SourceFile alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit diff --git a/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex new file mode 100644 index 000000000..234c75e14 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex @@ -0,0 +1,74 @@ +defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do + alias ElixirLS.LanguageServer.Protocol.TextEdit + alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast + alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff + + @spec apply(String.t(), Ast.t(), String.t() | atom) :: {:ok, [TextEdit.t()]} + def apply(original_text, ast, variable_name) do + variable_name = ensure_atom(variable_name) + + with {:ok, transformed} <- apply_transform(original_text, ast, variable_name) do + {:ok, to_text_edits(original_text, transformed)} + end + end + + defp to_text_edits(orig_text, fixed_text) do + orig_text + |> Diff.diff(fixed_text) + |> Enum.filter(&(&1.new_text == "_")) + end + + defp ensure_atom(variable_name) when is_binary(variable_name) do + String.to_atom(variable_name) + end + + defp ensure_atom(variable_name) when is_atom(variable_name) do + variable_name + end + + defp apply_transform(line_text, quoted_ast, unused_variable_name) do + underscored_variable_name = :"_#{unused_variable_name}" + leading_indent = leading_indent(line_text) + + Macro.postwalk(quoted_ast, fn + {^unused_variable_name, meta, context} -> + {underscored_variable_name, meta, context} + + other -> + other + end) + |> Macro.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 + |> fetch_line(0) + |> case do + {:ok, text} -> + {:ok, "#{leading_indent}#{text}"} + + error -> + 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 diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex index cb55ff60d..2adef98a1 100644 --- a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex @@ -2,107 +2,50 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn @moduledoc """ A code action that prefixes unused variables with an underscore """ - alias ElixirLS.LanguageServer.Experimental.Format.Diff + 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.Position - alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range - alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit alias ElixirLS.LanguageServer.Experimental.Protocol.Types.WorkspaceEdit alias ElixirLS.LanguageServer.Experimental.SourceFile @spec apply(CodeAction.t()) :: [CodeActionReply.t()] def apply(%CodeAction{} = code_action) do source_file = code_action.source_file - diagnostics = code_action.context.diagnostics + diagnostics = get_in(code_action, [:context, :diagnostics]) || [] diagnostics - |> Enum.reduce([], fn %Diagnostic{} = diagnostic, actions -> + |> Enum.flat_map(fn %Diagnostic{} = diagnostic -> with {:ok, variable_name, one_based_line} <- extract_variable_and_line(diagnostic), {:ok, reply} <- build_code_action(source_file, one_based_line, variable_name) do - [reply | actions] + [reply] else _ -> - actions + [] end end) - |> Enum.reverse() end defp build_code_action(%SourceFile{} = source_file, one_based_line, variable_name) do with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line), - {:ok, line_ast} <- - ElixirSense.string_to_quoted(line_text, 1, 6), - {:ok, transformed} <- apply_transform(line_text, line_ast, variable_name) do - text_edits = - line_text - |> to_text_edits(transformed) - |> update_lines(one_based_line) - - reply = - CodeActionResult.new( - title: "Rename to _#{variable_name}", - kind: :quick_fix, - edit: WorkspaceEdit.new(changes: %{source_file.uri => text_edits}) - ) - - {:ok, reply} - end - end - - defp to_text_edits(orig_text, fixed_text) do - orig_text - |> Diff.diff(fixed_text) - |> Enum.filter(&String.contains?(&1.new_text, "_")) - end - - defp update_lines(text_edits, one_based_line) do - Enum.map(text_edits, fn %TextEdit{} = text_edit -> - start_line = text_edit.range.start.line + one_based_line - 1 - end_line = text_edit.range.end.line + one_based_line - 1 - - %TextEdit{ - text_edit - | range: %Range{ - start: %Position{text_edit.range.start | line: start_line}, - end: %Position{text_edit.range.end | line: end_line} - } - } - end) - end - - defp apply_transform(line_text, quoted_ast, unused_variable_name) do - underscored_variable_name = :"_#{unused_variable_name}" - leading_indent = leading_indent(line_text) - - Macro.postwalk(quoted_ast, fn - {^unused_variable_name, meta, context} -> - {underscored_variable_name, meta, context} - - other -> - other - end) - |> Macro.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 - |> fetch_line(0) - |> case do - {:ok, text} -> - {:ok, "#{leading_indent}#{text}"} - - error -> - error - end - end - - @indent_regex ~r/^\s+/ - defp leading_indent(line_text) do - case Regex.scan(@indent_regex, line_text) do - [indent] -> indent - _ -> "" + {:ok, line_ast} <- Ast.from(line_text), + {:ok, text_edits} <- + CodeMod.ReplaceWithUnderscore.apply(line_text, line_ast, variable_name) do + case text_edits do + [] -> + :error + + [_ | _] -> + reply = + CodeActionResult.new( + title: "Rename to _#{variable_name}", + kind: :quick_fix, + edit: WorkspaceEdit.new(changes: %{source_file.uri => text_edits}) + ) + + {:ok, reply} + end end end @@ -127,16 +70,4 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn defp extract_line(%Diagnostic{} = diagnostic) do {:ok, diagnostic.range.start.line} 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 diff --git a/apps/language_server/lib/language_server/experimental/provider/handlers/formatting.ex b/apps/language_server/lib/language_server/experimental/provider/handlers/formatting.ex index 0bef9b664..0929a2e70 100644 --- a/apps/language_server/lib/language_server/experimental/provider/handlers/formatting.ex +++ b/apps/language_server/lib/language_server/experimental/provider/handlers/formatting.ex @@ -1,6 +1,6 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.Formatting do alias ElixirLS.LanguageServer.Experimental.Provider.Env - alias ElixirLS.LanguageServer.Experimental.Format + alias ElixirLS.LanguageServer.Experimental.CodeMod.Format alias ElixirLS.LanguageServer.Experimental.Protocol.Requests alias ElixirLS.LanguageServer.Experimental.Protocol.Responses require Logger diff --git a/apps/language_server/lib/language_server/experimental/source_file.ex b/apps/language_server/lib/language_server/experimental/source_file.ex index f9d3e32ff..d855872a3 100644 --- a/apps/language_server/lib/language_server/experimental/source_file.ex +++ b/apps/language_server/lib/language_server/experimental/source_file.ex @@ -34,6 +34,11 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do } end + @spec size(t) :: non_neg_integer() + def size(%__MODULE__{} = source) do + Document.size(source.document) + end + @spec mark_dirty(t) :: t def mark_dirty(%__MODULE__{} = source) do %__MODULE__{source | dirty?: true} diff --git a/apps/language_server/test/experimental/format/diff_test.exs b/apps/language_server/test/experimental/code_mod/diff_test.exs similarity index 52% rename from apps/language_server/test/experimental/format/diff_test.exs rename to apps/language_server/test/experimental/code_mod/diff_test.exs index 7bc2e569e..7869f73d6 100644 --- a/apps/language_server/test/experimental/format/diff_test.exs +++ b/apps/language_server/test/experimental/code_mod/diff_test.exs @@ -1,11 +1,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do - alias ElixirLS.LanguageServer.Experimental.Format.Diff + alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Position alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit import Diff - use ExUnit.Case + + use ElixirLS.Test.CodeMod.Case def edit(start_line, start_code_unit, end_line, end_code_unit, replacement) do TextEdit.new( @@ -18,13 +19,24 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do ) end + def apply_code_mod(source, _, opts) do + result = Keyword.get(opts, :result) + {:ok, Diff.diff(source, result)} + end + + def assert_edited(initial, final) do + assert {:ok, edited} = modify(initial, result: final, convert_to_ast: false) + 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(0, 0, 0, 2, "") == edit + assert edit == edit(0, 0, 0, 2, "") + assert_edited(orig, final) end test "appending in the middle" do @@ -32,7 +44,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do final = "heyello" assert [edit] = diff(orig, final) - assert edit(0, 2, 0, 2, "ye") == edit + assert edit == edit(0, 2, 0, 2, "ye") + assert_edited(orig, final) end test "deleting in the middle" do @@ -40,7 +53,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do final = "heo" assert [edit] = diff(orig, final) - assert edit(0, 2, 0, 4, "") == edit + assert edit == edit(0, 2, 0, 4, "") + assert_edited(orig, final) end test "inserting after a delete" do @@ -50,7 +64,26 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do # this is collapsed into a single edit of an # insert that spans the delete and the insert assert [edit] = diff(orig, final) - assert edit(0, 3, 0, 5, "vetica went") == edit + 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 @@ -67,7 +100,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do final = "hello" assert [edit] = diff(orig, final) - assert edit(0, 0, 2, 0, "") == edit + assert edit == edit(0, 0, 2, 0, "") + assert_edited(orig, final) end test "multi-line appending in the middle" do @@ -75,7 +109,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do final = "he\n\n ye\n\nllo" assert [edit] = diff(orig, final) - assert edit(0, 2, 0, 2, "\n\n ye\n\n") == edit + 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 @@ -91,7 +126,23 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do final = "hellogoodbye" assert [edit] = diff(orig, final) - assert edit(0, 5, 3, 0, "") == edit + 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 @@ -114,7 +165,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do |> String.trim() assert [edit] = diff(orig, final) - assert edit(2, 0, 4, 0, "") == edit + assert edit == edit(2, 0, 4, 0, "") + assert_edited(orig, final) end end @@ -124,7 +176,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do final = ~S[{"🎸", "after"}] assert [edit] = diff(orig, final) - assert edit(0, 7, 0, 9, "") == edit + assert edit == edit(0, 7, 0, 9, "") + assert_edited(orig, final) end test "inserting in the middle" do @@ -132,18 +185,55 @@ defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do final = ~S[🎸🎺🎸] assert [edit] = diff(orig, final) - assert edit(0, 2, 0, 2, "🎺") == edit + 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 - assert edit(0, 2, 0, 6, "") == edit + 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/experimental/code_mod/format_test.exs b/apps/language_server/test/experimental/code_mod/format_test.exs new file mode 100644 index 000000000..3792a1824 --- /dev/null +++ b/apps/language_server/test/experimental/code_mod/format_test.exs @@ -0,0 +1,88 @@ +defmodule ElixirLS.Experimental.FormatTest do + alias ElixirLS.LanguageServer.Experimental.CodeMod.Format + alias ElixirLS.LanguageServer.Experimental.SourceFile + + use ElixirLS.Test.CodeMod.Case + + def apply_code_mod(text, _ast, opts) do + file_path = Keyword.get_lazy(opts, :file_path, &File.cwd!/0) + + text + |> source_file() + |> Format.text_edits(file_path) + end + + def source_file(text) do + SourceFile.new("file://#{__ENV__.file}", text, 1) + end + + def unformatted do + ~q[ + defmodule Unformatted do + def something( a, b ) do + end + end + ]t + end + + def formatted do + ~q[ + defmodule Unformatted do + def something(a, b) do + end + end + ]t + end + + describe "format/2" do + test "it should be able to format a file in the project" do + {:ok, result} = modify(unformatted()) + + assert result == formatted() + end + + test "it should be able to format a file when the project isn't specified" do + assert {:ok, result} = modify(unformatted(), file_path: nil) + assert result == formatted() + end + + test "it should provide an error for a syntax error" do + assert {:error, %SyntaxError{}} = ~q[ + def foo(a, ) do + true + end + ] |> modify() + end + + test "it should provide an error for a missing token" do + assert {:error, %TokenMissingError{}} = ~q[ + defmodule TokenMissing do + :bad + ] |> modify() + end + + test "it correctly handles unicode" do + assert {:ok, result} = ~q[ + {"🎸", "o"} + ] |> modify() + + assert ~q[ + {"🎸", "o"} + ]t == result + end + + test "it handles extra lines" do + assert {:ok, result} = ~q[ + defmodule Unformatted do + def something( a , b) do + + + + end + end + ] |> modify() + + assert result == formatted() + end + end +end diff --git a/apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs b/apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs new file mode 100644 index 000000000..4f5d7d9aa --- /dev/null +++ b/apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs @@ -0,0 +1,214 @@ +defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscoreTest do + alias ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore + + use ElixirLS.Test.CodeMod.Case + + def apply_code_mod(original_text, ast, options) do + variable = Keyword.get(options, :variable, :unused) + ReplaceWithUnderscore.apply(original_text, ast, variable) + 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 + 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 +end diff --git a/apps/language_server/test/experimental/formatter_test.exs b/apps/language_server/test/experimental/formatter_test.exs deleted file mode 100644 index c86cbaeec..000000000 --- a/apps/language_server/test/experimental/formatter_test.exs +++ /dev/null @@ -1,74 +0,0 @@ -defmodule ElixirLS.Experimental.FormatterTest do - alias ElixirLS.LanguageServer.Experimental.Format - alias ElixirLS.LanguageServer.Experimental.SourceFile - - use ExUnit.Case - - def source_file(text) do - SourceFile.new("file://#{__ENV__.file}", text, 1) - end - - def apply_format(text) do - source = source_file(text) - Format.format(source, File.cwd!()) - end - - def elixir_format(text) do - iodata = Code.format_string!(text, []) - - IO.iodata_to_binary([iodata, ?\n]) - end - - def unformatted do - """ - defmodule Unformatted do - def something()do - end - end - """ - end - - describe "format/2" do - test "it should be able to forma a file in the project" do - assert {:ok, formatted} = apply_format(unformatted()) - assert formatted == elixir_format(unformatted()) - end - - test "it should be able to format a file when the project isn't specified" do - assert {:ok, formatted} = unformatted() |> source_file() |> Format.format(nil) - assert formatted == elixir_format(unformatted()) - end - - test "it should provide an error for a syntax error" do - missing_comma = """ - def foo(a, ) do - true - end - """ - - assert {:error, %SyntaxError{}} = apply_format(missing_comma) - end - - test "it should provide an error for a missing token" do - missing_token = """ - defmodule TokenMissing do - :bad - """ - - assert {:error, %TokenMissingError{}} = apply_format(missing_token) - end - - test "it correctly handles unicode" do - orig = """ - {"🎸", "o"} - """ - - expected = """ - {"🎸", "o"} - """ - - assert {:ok, formatted} = apply_format(orig) - assert expected == formatted - end - end -end diff --git a/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs index 41c8a7951..71feb57bb 100644 --- a/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs +++ b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs @@ -5,7 +5,6 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeActionContext alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Diagnostic alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range - alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextDocument.ContentChangeEvent alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore alias ElixirLS.LanguageServer.Experimental.SourceFile alias ElixirLS.LanguageServer.Fixtures.LspProtocol @@ -17,6 +16,11 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn use ExUnit.Case use Patch + setup do + {:ok, _} = start_supervised(SourceFile.Store) + :ok + end + def diagnostic_message(file_path, line, variable_name, {module_name, function_name, arity}) do """ warning: variable "#{variable_name}" is unused (if the variable is not meant to be used, prefix it with an underscore) @@ -28,7 +32,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn trimmed_body = String.trim(file_body, "\n") file_uri = SourceFilePath.to_uri(file_path) - patch(SourceFile.Store, :fetch, {:ok, SourceFile.new(file_uri, trimmed_body, 1)}) + SourceFile.Store.open(file_uri, trimmed_body, 0) {:ok, range} = build(Range, @@ -39,7 +43,11 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn message_file_path = Keyword.get(opts, :message_file_path, file_path) mfa = Keyword.get(opts, :mfa, {"MyModule", "myfunc", 1}) - message = diagnostic_message(message_file_path, line, variable_name, mfa) + message = + Keyword.get_lazy(opts, :diagnostic_message, fn -> + diagnostic_message(message_file_path, line, variable_name, mfa) + end) + diagnostic = Diagnostic.new(range: range, message: message) {:ok, context} = build(CodeActionContext, diagnostics: [diagnostic]) @@ -51,7 +59,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn ) {:ok, action} = Requests.to_elixir(action) - {file_uri, trimmed_body, action} + {file_uri, action} end def to_map(%Range{} = range) do @@ -60,254 +68,67 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn |> JasonVendored.decode!() end - def apply_edit(source, edits, opts \\ []) do - source_file = SourceFile.new("file:///none", source, 1) - - converted_edits = - Enum.map(edits, fn edit -> - ContentChangeEvent.new(text: edit.new_text, range: edit.range) - end) - - {:ok, source} = SourceFile.apply_content_changes(source_file, 3, converted_edits) - - if Keyword.get(opts, :trim, true) do - source - |> SourceFile.to_string() - |> String.trim() - else - SourceFile.to_string(source) - end + test "produces no actions if the name or variable is not found" do + assert {_, action} = code_action("other_var = 6", "/project/file.ex", 1, "not_found") + assert [] = apply(action) end test "produces no actions if the line is empty" do - {_, _, action} = code_action("", "/project/file.ex", 1, "a") + {_, action} = code_action("", "/project/file.ex", 1, "a") assert [] = apply(action) end - describe "fixes in parameters" do - test "applied to an unadorned param" do - {file_uri, source, code_action} = - ~S[ - def my_func(a) do - ] - |> code_action("/project/file.ex", 0, "a") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "def my_func(_a) do" == apply_edit(source, edit) - end - - test "applied to a pattern match in params" do - {file_uri, source, code_action} = - ~S[ - def my_func(%SourceFile{} = unused) do - ] - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "def my_func(%SourceFile{} = _unused) do" = apply_edit(source, edit) - end - - test "applied to a pattern match preceding a struct in params" do - {file_uri, source, code_action} = - ~S[ - def my_func(unused = %SourceFile{}) do - ] - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "def my_func(_unused = %SourceFile{}) do" = apply_edit(source, edit) - end - - test "applied prior to a map" do - {file_uri, source, code_action} = - ~S[ - def my_func(unused = %{}) do - ] - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "def my_func(_unused = %{}) do" = apply_edit(source, edit) - end - - test "applied after a map %{} = unused" do - {file_uri, source, code_action} = - ~S[ - def my_func(%{} = unused) do - ] - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "def my_func(%{} = _unused) do" = apply_edit(source, edit) - end + test "produces no results if the diagnostic message doesn't fit the format" do + assert {_, action} = + code_action("", "/project/file.ex", 1, "not_found", + diagnostic_message: "This isn't cool" + ) - test "applied to a map key %{foo: unused}" do - {file_uri, source, code_action} = - ~S[ - def my_func(%{foo: unused}) do - ] - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "def my_func(%{foo: _unused}) do" = apply_edit(source, edit) - end - - test "applied to a list element params = [unused, a, b | rest]" do - {file_uri, source, code_action} = - ~S{ - def my_func([unused, a, b | rest]) do - } - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "def my_func([_unused, a, b | rest]) do" = apply_edit(source, edit) - end - - test "applied to the tail of a list params = [a, b, | unused]" do - {file_uri, source, code_action} = - ~S{ - def my_func([a, b | unused]) do - } - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "def my_func([a, b | _unused]) do" = apply_edit(source, edit) - end + assert [] = apply(action) end - describe "fixes in variables" do - test "applied to a variable match " do - {file_uri, source, code_action} = - ~S[ - x = 3 + test "produces no results for buggy source code" do + {_, action} = + ~S[ + 1 + 2~/3 ; 4ab( ] - |> code_action("/project/file.ex", 0, "x", mfa: {"iex", "nofunction", 0}) - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + |> code_action("/project/file.ex", 0, "unused") - assert "_x = 3" == apply_edit(source, edit) - end - - test "applied to a variable match, preserves comments" do - {file_uri, source, code_action} = - ~S[ - a = bar # TODO: Fix this - ] - |> code_action("/project/file.ex", 0, "a") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "_a = bar # TODO: Fix this" == apply_edit(source, edit) - end - - test "preserves spacing" do - {file_uri, source, code_action} = - " x = 3" - |> code_action("/project/file.ex", 0, "x", mfa: {"iex", "nofunction", 1}) - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - - assert " _x = 3" == apply_edit(source, edit, trim: false) - end - - test "applied to a variable with a pattern matched struct" do - {file_uri, source, code_action} = - ~S[ - unused = %Struct{} - ] - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "_unused = %Struct{}" = apply_edit(source, edit) - end - - test "applied to a variable with a pattern matched struct preserves trailing comments" do - {file_uri, source, code_action} = - ~S[ - unused = %Struct{} # TODO: fix - ] - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "_unused = %Struct{} # TODO: fix" = apply_edit(source, edit) - end - - test "applied to struct param matches" do - {file_uri, source, code_action} = - ~S[ - %Struct{field: unused, other_field: used} - ] - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "%Struct{field: _unused, other_field: used}" = apply_edit(source, edit) - end - - test "applied to a struct module match %module{}" do - {file_uri, source, code_action} = - ~S[ - %unused{field: first, other_field: used} - ] - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "%_unused{field: first, other_field: used}" = apply_edit(source, edit) - end + assert [] = apply(action) + end - test "applied to a tuple value" do - {file_uri, source, code_action} = - ~S[ - {a, b, unused, c} = whatever - ] - |> code_action("/project/file.ex", 0, "unused") + test "handles nil context" do + assert {_, action} = code_action("other_var = 6", "/project/file.ex", 1, "not_found") - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "{a, b, _unused, c} = whatever" = apply_edit(source, edit) - end + action = put_in(action, [:context], nil) - test "applied to a list element" do - {file_uri, source, code_action} = - ~S{ - [a, b, unused, c] = whatever - } - |> code_action("/project/file.ex", 0, "unused") + assert [] = apply(action) + end - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "[a, b, _unused, c] = whatever" = apply_edit(source, edit) - end + test "handles nil diagnostics" do + assert {_, action} = code_action("other_var = 6", "/project/file.ex", 1, "not_found") - test "applied to map value" do - {file_uri, source, code_action} = - ~S[ - %{foo: a, bar: unused} = whatever - ] - |> code_action("/project/file.ex", 0, "unused") + action = put_in(action, [:context, :diagnostics], nil) - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) - assert "%{foo: a, bar: _unused} = whatever" = apply_edit(source, edit) - end + assert [] = apply(action) end - describe "fixes in structures" do - test "applied to a match of a comprehension" do - {file_uri, source, code_action} = - "for {unused, something_else} <- my_enum, do: something_else" - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + test "handles empty diagnostics" do + assert {_, action} = code_action("other_var = 6", "/project/file.ex", 1, "not_found") - assert "for {_unused, something_else} <- my_enum, do: something_else" == - apply_edit(source, edit) - end + action = put_in(action, [:context, :diagnostics], []) - test "applied to a match in a with block" do - {file_uri, source, code_action} = - "with {unused, something_else} <- my_enum, do: something_else" - |> code_action("/project/file.ex", 0, "unused") - - assert [%CodeActionReply{edit: %{changes: %{^file_uri => edit}}}] = apply(code_action) + assert [] = apply(action) + end - expected = "with {_unused, something_else} <- my_enum, do: something_else" + test "applied to an unadorned param" do + {file_uri, code_action} = + ~S[ + def my_func(a) do + ] + |> code_action("/project/file.ex", 0, "a") - assert String.trim(expected) == apply_edit(source, edit) - end + assert [%CodeActionReply{edit: %{changes: %{^file_uri => [edit]}}}] = apply(code_action) + assert edit.new_text == "_" end end diff --git a/apps/language_server/test/support/experimental/code_mod/code_mod_case.ex b/apps/language_server/test/support/experimental/code_mod/code_mod_case.ex new file mode 100644 index 000000000..1c0d53002 --- /dev/null +++ b/apps/language_server/test/support/experimental/code_mod/code_mod_case.ex @@ -0,0 +1,92 @@ +defmodule ElixirLS.Test.CodeMod.Case do + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextDocument.ContentChangeEvent + alias ElixirLS.LanguageServer.Experimental.SourceFile + + use ExUnit.CaseTemplate + + using do + quote do + import unquote(__MODULE__), only: [sigil_q: 2] + + def apply_code_mod(_, _, _) do + {:error, "You must implement apply_code_mod/3"} + end + + defoverridable apply_code_mod: 3 + + def modify(original, options \\ []) do + with {:ok, ast} <- maybe_convert_to_ast(original, options), + {:ok, edits} <- apply_code_mod(original, ast, options) do + {:ok, unquote(__MODULE__).apply_edits(original, edits, options)} + end + end + + defp maybe_convert_to_ast(code, options) do + alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast + + if Keyword.get(options, :convert_to_ast, true) do + Ast.from(code) + else + {:ok, nil} + end + end + end + end + + def sigil_q(text, opts \\ []) do + ["", first | rest] = text |> String.split("\n") + base_indent = indent(first) + indent_length = String.length(base_indent) + + Enum.map_join([first | rest], "\n", &strip_leading_indent(&1, indent_length)) + |> maybe_trim(opts) + end + + def apply_edits(original, text_edits, opts) do + source_file = SourceFile.new("file:///file.ex", original, 0) + + converted_edits = + Enum.map(text_edits, fn edit -> + ContentChangeEvent.new(text: edit.new_text, range: edit.range) + end) + + {:ok, edited_source_file} = SourceFile.apply_content_changes(source_file, 1, converted_edits) + edited_source = SourceFile.to_string(edited_source_file) + + if Keyword.get(opts, :trim, true) do + String.trim(edited_source) + else + edited_source + end + 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 + + @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 +end From 2b04d83e83dc256d4789669768aaf4d0485c9702 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Wed, 30 Nov 2022 13:15:16 -0800 Subject: [PATCH 10/21] Simplified diff, change name of code action functions from appy to text_edits --- .../experimental/code_mod/diff.ex | 53 ++++++++++--------- .../code_mod/replace_with_underscore.ex | 4 +- .../code_action/replace_with_underscore.ex | 2 +- .../code_mod/replace_with_underscore_test.exs | 2 +- .../provider/handlers/code_action.ex | 13 ----- 5 files changed, 31 insertions(+), 43 deletions(-) delete mode 100644 apps/language_server/test/experimental/provider/handlers/code_action.ex diff --git a/apps/language_server/lib/language_server/experimental/code_mod/diff.ex b/apps/language_server/lib/language_server/experimental/code_mod/diff.ex index 2c28fd94f..94395095a 100644 --- a/apps/language_server/lib/language_server/experimental/code_mod/diff.ex +++ b/apps/language_server/lib/language_server/experimental/code_mod/diff.ex @@ -12,20 +12,18 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Diff do end defp to_text_edits(difference) do - {_, edits} = - Enum.reduce(difference, {{0, 0}, []}, fn {diff_type, diff_string}, {position, edits} -> - apply_diff(diff_type, position, diff_string, edits) + {_, {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) - edits - |> Enum.reduce([], &collapse/2) - - # Sorting in reverse by start character and line ensures edits are applied back to front on - # throughout a document which means deletes won't affect subsequent edits and mess up their - # start / end ranges - # TODO: This would be more easily accomplished by adding edits to a list for each line - # and then flat_mapping the result - |> Enum.sort_by(fn edit -> {edit.range.start.line, edit.range.start.character} end, :desc) + [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 an insert that are adjacent to one another @@ -60,36 +58,39 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Diff do end defp apply_diff(:eq, position, doc_string, edits) do - new_position = advance(doc_string, position) - {new_position, edits} + advance(doc_string, position, edits) end defp apply_diff(:del, {line, code_unit} = position, change, edits) do - after_pos = {edit_end_line, edit_end_unit} = advance(change, position) - {after_pos, [edit("", line, code_unit, edit_end_line, edit_end_unit) | edits]} + {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, edits) do - {advance(change, position), [edit(change, line, code_unit, line, code_unit) | edits]} + 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] + advance(change, position, {current_line, prev_lines}) end - def advance(<<>>, position) do - position + defp advance(<<>>, position, edits) do + {position, edits} end for ending <- ["\r\n", "\r", "\n"] do - def advance(<>, {line, _unit}) do - advance(rest, {line + 1, 0}) + defp advance(<>, {line, _unit}, {current_line, prev_lines}) do + edits = {[], [current_line | prev_lines]} + advance(rest, {line + 1, 0}, edits) end end - def advance(<>, {line, unit}) when c < 128 do - advance(rest, {line, unit + 1}) + defp advance(<>, {line, unit}, edits) when c < 128 do + advance(rest, {line, unit + 1}, edits) end - def advance(<>, {line, unit}) do + defp advance(<>, {line, unit}, edits) do increment = CodeUnit.count(:utf16, <>) - advance(rest, {line, unit + increment}) + advance(rest, {line, unit + increment}, edits) end defp edit(text, start_line, start_unit, end_line, end_unit) do diff --git a/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex index 234c75e14..fd32abc77 100644 --- a/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex @@ -3,8 +3,8 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff - @spec apply(String.t(), Ast.t(), String.t() | atom) :: {:ok, [TextEdit.t()]} - def apply(original_text, ast, variable_name) do + @spec text_edits(String.t(), Ast.t(), String.t() | atom) :: {:ok, [TextEdit.t()]} + def text_edits(original_text, ast, variable_name) do variable_name = ensure_atom(variable_name) with {:ok, transformed} <- apply_transform(original_text, ast, variable_name) do diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex index 2adef98a1..5ce3a8e89 100644 --- a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex @@ -31,7 +31,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line), {:ok, line_ast} <- Ast.from(line_text), {:ok, text_edits} <- - CodeMod.ReplaceWithUnderscore.apply(line_text, line_ast, variable_name) do + CodeMod.ReplaceWithUnderscore.text_edits(line_text, line_ast, variable_name) do case text_edits do [] -> :error diff --git a/apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs b/apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs index 4f5d7d9aa..3730f93d0 100644 --- a/apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs +++ b/apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs @@ -5,7 +5,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscoreTest def apply_code_mod(original_text, ast, options) do variable = Keyword.get(options, :variable, :unused) - ReplaceWithUnderscore.apply(original_text, ast, variable) + ReplaceWithUnderscore.text_edits(original_text, ast, variable) end describe "fixes in parameters" do diff --git a/apps/language_server/test/experimental/provider/handlers/code_action.ex b/apps/language_server/test/experimental/provider/handlers/code_action.ex deleted file mode 100644 index 771e74920..000000000 --- a/apps/language_server/test/experimental/provider/handlers/code_action.ex +++ /dev/null @@ -1,13 +0,0 @@ -defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do - alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore - alias ElixirLS.LanguageServer.Experimental.Provider.Env - alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction - ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore - - def handle(%CodeAction{} = request, %Env{}) do - case ReplaceWithUnderscore.apply(reqest) do - [] -> - nil - end - end -end From a185b007d964ea7ee79d4b396bd05fed16b31064 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Wed, 30 Nov 2022 20:21:11 -0800 Subject: [PATCH 11/21] Fixed off-by-one error that was vexing code unit conversions. The problem was that the character positions are _before_ the reported unit, so the 0th code unit is before the start of the line and the 1st code unit is the first character. The prior code added one to character counts to smooth this out, but you can't do that, because you could end up indexing into the middle of a multibyte character. --- .../language_server/experimental/code_unit.ex | 8 +-- .../experimental/source_file/conversions.ex | 50 ++++++++---------- .../test/experimental/code_unit_test.exs | 51 +++++++++++-------- 3 files changed, 53 insertions(+), 56 deletions(-) diff --git a/apps/language_server/lib/language_server/experimental/code_unit.ex b/apps/language_server/lib/language_server/experimental/code_unit.ex index 14ad0b822..96ab4a658 100644 --- a/apps/language_server/lib/language_server/experimental/code_unit.ex +++ b/apps/language_server/lib/language_server/experimental/code_unit.ex @@ -44,12 +44,12 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeUnit do @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 + 1, 0) + 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 + 1, 0) + do_to_utf16(binary, utf16_unit, 0) end def count(:utf16, binary) do @@ -98,7 +98,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeUnit do end defp do_to_utf16(_, 0, utf16_unit) do - {:ok, utf16_unit - 1} + {:ok, utf16_unit} end defp do_to_utf16(_, utf8_unit, _) when utf8_unit < 0 do @@ -152,7 +152,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeUnit do end defp do_to_utf8(_, 0, utf8_unit) do - {:ok, utf8_unit - 1} + {:ok, utf8_unit} end defp do_to_utf8(_, utf_16_units, _) when utf_16_units < 0 do diff --git a/apps/language_server/lib/language_server/experimental/source_file/conversions.ex b/apps/language_server/lib/language_server/experimental/source_file/conversions.ex index 9d4a91b51..c019b74fb 100644 --- a/apps/language_server/lib/language_server/experimental/source_file/conversions.ex +++ b/apps/language_server/lib/language_server/experimental/source_file/conversions.ex @@ -81,17 +81,8 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do {:ok, ElixirPosition.new(elixir_line_number, 0)} true -> - with {:ok, line} <- Document.fetch_line(document, elixir_line_number) do - elixir_character = - case line do - line(ascii?: true, text: text) -> - min(ls_character, byte_size(text)) - - line(text: text) -> - {:ok, utf16_text} = to_utf16(text) - lsp_character_to_elixir(utf16_text, ls_character) - end - + with {:ok, line} <- Document.fetch_line(document, elixir_line_number), + {:ok, elixir_character} <- extract_elixir_character(position, line) do {:ok, ElixirPosition.new(elixir_line_number, elixir_character)} end end @@ -123,20 +114,11 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do end def to_lsp(%ElixirPosition{} = position, %Document{} = document) do - %ElixirPosition{character: elixir_character, line: elixir_line} = position + with {:ok, line} <- Document.fetch_line(document, position.line), + {:ok, lsp_character} <- extract_lsp_character(position, line) do + ls_pos = + LSPosition.new(character: lsp_character, line: position.line - @elixir_ls_index_base) - with {:ok, line} <- Document.fetch_line(document, elixir_line) do - lsp_character = - case line do - line(ascii?: true, text: text) -> - min(position.character, byte_size(text)) - - line(text: utf8_text) -> - {:ok, character} = elixir_character_to_lsp(utf8_text, elixir_character) - character - end - - ls_pos = LSPosition.new(character: lsp_character, line: elixir_line - @elixir_ls_index_base) {:ok, ls_pos} end end @@ -147,19 +129,27 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do # Private - defp extract_lsp_character(%ElixirPosition{} = position, line(ascii?: true)) do - {:ok, position.character} + defp extract_lsp_character(%ElixirPosition{} = position, line(ascii?: true, text: text)) do + character = min(position.character, byte_size(text)) + {:ok, character} end defp extract_lsp_character(%ElixirPosition{} = position, line(text: utf8_text)) do - {:ok, CodeUnit.utf16_offset(utf8_text, position.character)} + with {:ok, code_unit} <- CodeUnit.to_utf16(utf8_text, position.character) do + character = min(code_unit, CodeUnit.count(:utf16, utf8_text)) + {:ok, character} + end end - defp extract_elixir_character(%LSPosition{} = position, line(ascii?: true)) do - {:ok, position.character} + defp extract_elixir_character(%LSPosition{} = position, line(ascii?: true, text: text)) do + character = min(position.character, byte_size(text)) + {:ok, character} end defp extract_elixir_character(%LSPosition{} = position, line(text: utf8_text)) do - {:ok, CodeUnit.utf8_offset(utf8_text, position.character)} + with {:ok, code_unit} <- CodeUnit.to_utf8(utf8_text, position.character) do + character = min(code_unit, byte_size(utf8_text)) + {:ok, character} + end end end diff --git a/apps/language_server/test/experimental/code_unit_test.exs b/apps/language_server/test/experimental/code_unit_test.exs index 69b5ac985..06b602065 100644 --- a/apps/language_server/test/experimental/code_unit_test.exs +++ b/apps/language_server/test/experimental/code_unit_test.exs @@ -64,6 +64,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeUnitTest do end test "handles multi-byte characters properly" do + # guitar is 2 code units in utf16 but 4 in utf8 line = "b🎸abc" assert 0 == utf16_offset(line, 0) assert 1 == utf16_offset(line, 1) @@ -77,19 +78,21 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeUnitTest do describe "converting to utf8" do test "bounds are respected" do - assert {:error, :out_of_bounds} = to_utf16("h", 1) + assert {:error, :out_of_bounds} = to_utf16("h", 2) end test "with a multi-byte character" do line = "🏳️‍🌈" + code_unit_count = count_utf8_code_units(line) - assert to_utf8(line, 0) == {:error, :misaligned} - assert to_utf8(line, 1) == {:ok, 3} - assert to_utf8(line, 2) == {:ok, 6} - assert to_utf8(line, 3) == {:ok, 9} - assert to_utf8(line, 4) == {:error, :misaligned} - assert to_utf8(line, 5) == {:ok, code_unit_count - 1} + assert to_utf8(line, 0) == {:ok, 0} + assert to_utf8(line, 1) == {:error, :misaligned} + assert to_utf8(line, 2) == {:ok, 4} + assert to_utf8(line, 3) == {:ok, 7} + assert to_utf8(line, 4) == {:ok, 10} + assert to_utf8(line, 5) == {:error, :misaligned} + assert to_utf8(line, 6) == {:ok, code_unit_count} end test "after a unicode character" do @@ -99,8 +102,8 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeUnitTest do assert to_utf8(line, 1) == {:ok, 1} assert to_utf8(line, 4) == {:ok, 4} assert to_utf8(line, 5) == {:ok, 5} - assert to_utf8(line, 6) == {:error, :misaligned} - assert to_utf8(line, 7) == {:ok, 9} + assert to_utf8(line, 6) == {:ok, 6} + assert to_utf8(line, 7) == {:error, :misaligned} # after the guitar character assert to_utf8(line, 8) == {:ok, 10} assert to_utf8(line, 9) == {:ok, 11} @@ -114,24 +117,27 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeUnitTest do describe "converting to utf16" do test "respects bounds" do - assert {:error, :out_of_bounds} = to_utf16("h", 1) + assert {:error, :out_of_bounds} = to_utf16("h", 2) end test "with a multi-byte character" do line = "🏳️‍🌈" + code_unit_count = count_utf16_code_units(line) utf8_code_unit_count = count_utf8_code_units(line) - assert to_utf16(line, 0) == {:error, :misaligned} + assert to_utf16(line, 0) == {:ok, 0} assert to_utf16(line, 1) == {:error, :misaligned} assert to_utf16(line, 2) == {:error, :misaligned} - assert to_utf16(line, 3) == {:ok, 1} - assert to_utf16(line, 4) == {:error, :misaligned} - assert to_utf16(line, utf8_code_unit_count - 1) == {:ok, code_unit_count - 1} + assert to_utf16(line, 3) == {:error, :misaligned} + assert to_utf16(line, 4) == {:ok, 2} + assert to_utf16(line, utf8_code_unit_count - 1) == {:error, :misaligned} + assert to_utf16(line, utf8_code_unit_count) == {:ok, code_unit_count} end test "after a multi-byte character" do line = " {\"🎸\", \"ok\"}" + utf16_code_unit_count = count_utf16_code_units(line) utf8_code_unit_count = count_utf8_code_units(line) @@ -140,11 +146,12 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeUnitTest do assert to_utf16(line, index) == {:ok, index} end - assert to_utf16(line, 6) == {:error, :misaligned} + assert to_utf16(line, 6) == {:ok, 6} assert to_utf16(line, 7) == {:error, :misaligned} assert to_utf16(line, 8) == {:error, :misaligned} + assert to_utf16(line, 9) == {:error, :misaligned} - for index <- 9..17 do + for index <- 10..19 do assert to_utf16(line, index) == {:ok, index - 2} end @@ -157,11 +164,11 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeUnitTest do utf8_code_unit_count = count_utf8_code_units(s) utf16_unit_count = count_utf16_code_units(s) - assert {:ok, utf16_unit} = to_utf16(s, utf8_code_unit_count - 1) - assert utf16_unit == utf16_unit_count - 1 + assert {:ok, utf16_unit} = to_utf16(s, utf8_code_unit_count) + assert utf16_unit == utf16_unit_count assert {:ok, utf8_unit} = to_utf8(s, utf16_unit) - assert utf8_unit == utf8_code_unit_count - 1 + assert utf8_unit == utf8_code_unit_count end end @@ -170,11 +177,11 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeUnitTest do utf16_code_unit_count = count_utf16_code_units(s) utf8_code_unit_count = count_utf8_code_units(s) - assert {:ok, utf8_code_unit} = to_utf8(s, utf16_code_unit_count - 1) - assert utf8_code_unit == utf8_code_unit_count - 1 + assert {:ok, utf8_code_unit} = to_utf8(s, utf16_code_unit_count) + assert utf8_code_unit == utf8_code_unit_count assert {:ok, utf16_unit} = to_utf16(s, utf8_code_unit) - assert utf16_unit == utf16_code_unit_count - 1 + assert utf16_unit == utf16_code_unit_count end end From d04d93cc284c87e7c1814f8c563ac5e0319a015f Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Thu, 1 Dec 2022 22:04:52 -0800 Subject: [PATCH 12/21] The code action needs to fix up the line numbers Code mods deal with snippets of code that need to have their line numbers fixed up by the code actions. --- .../code_action/replace_with_underscore.ex | 10 ++++++++++ .../code_action/replace_with_underscore_test.exs | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex index 5ce3a8e89..4ef868c35 100644 --- a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex @@ -2,11 +2,13 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn @moduledoc """ A code action that prefixes unused variables with an underscore """ + 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.WorkspaceEdit alias ElixirLS.LanguageServer.Experimental.SourceFile @@ -37,6 +39,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn :error [_ | _] -> + text_edits = Enum.map(text_edits, &update_line(&1, one_based_line)) + reply = CodeActionResult.new( title: "Rename to _#{variable_name}", @@ -49,6 +53,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn 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 extract_variable_and_line(%Diagnostic{} = diagnostic) do with {:ok, variable_name} <- extract_variable_name(diagnostic.message), {:ok, line} <- extract_line(diagnostic) do diff --git a/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs index 71feb57bb..baa1195f0 100644 --- a/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs +++ b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs @@ -131,4 +131,18 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn assert [%CodeActionReply{edit: %{changes: %{^file_uri => [edit]}}}] = apply(code_action) assert edit.new_text == "_" end + + test "works with multiple lines" do + {file_uri, code_action} = ~S[ + defmodule MyModule do + def my_func(a) do + end + end + ] |> code_action("/project/file.ex", 1, "a") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => [edit]}}}] = apply(code_action) + assert edit.new_text == "_" + assert edit.range.start.line == 1 + assert edit.range.end.line == 1 + end end From 302ebc9c7623ce52373eb5d1bed7f3c14e4f4fbb Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Thu, 1 Dec 2022 22:05:57 -0800 Subject: [PATCH 13/21] Fixed type spec The AST type is very complicated, and dialyzer was telling us I got it wrong. --- .../lib/language_server/experimental/code_mod/ast.ex | 3 ++- .../experimental/code_mod/replace_with_underscore.ex | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/language_server/lib/language_server/experimental/code_mod/ast.ex b/apps/language_server/lib/language_server/experimental/code_mod/ast.ex index 0fd0df006..b5172d301 100644 --- a/apps/language_server/lib/language_server/experimental/code_mod/ast.ex +++ b/apps/language_server/lib/language_server/experimental/code_mod/ast.ex @@ -1,6 +1,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Ast do alias ElixirLS.LanguageServer.Experimental.SourceFile - @opaque t :: tuple() + + @type t :: any() def from(%SourceFile{} = source_file) do source_file diff --git a/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex index fd32abc77..ba44fc387 100644 --- a/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex @@ -3,7 +3,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff - @spec text_edits(String.t(), Ast.t(), String.t() | atom) :: {:ok, [TextEdit.t()]} + @spec text_edits(String.t(), Ast.t(), String.t() | atom) :: {:ok, [TextEdit.t()]} | :error def text_edits(original_text, ast, variable_name) do variable_name = ensure_atom(variable_name) From 840caeb43be69e4d370e17c17d184afa0edc6904 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Thu, 15 Dec 2022 15:37:16 -0800 Subject: [PATCH 14/21] Made type aliases a thing While working on the automatic protocol generators, it became clear that type aliases needed to be their own thing, as they operate quite differently from the other defined things in the jsonrpc protocol. Since they're just aliases, it makes sense to keep their definitions on hand and then spit them out when other things make use of them during encode and decode. This did require going back to encoding and ensuring all the encode functions return OK tuples. --- apps/language_server/.formatter.exs | 1 + .../experimental/protocol/proto.ex | 1 + .../experimental/protocol/proto/alias.ex | 22 +++++ .../protocol/proto/compile_metadata.ex | 9 ++ .../experimental/protocol/proto/enum.ex | 6 +- .../experimental/protocol/proto/field.ex | 93 ++++++++++++++++--- .../protocol/proto/macros/json.ex | 2 +- .../protocol/proto/type_functions.ex | 8 ++ .../test/experimental/protocol/proto_test.exs | 53 +++++++++++ 9 files changed, 178 insertions(+), 17 deletions(-) create mode 100644 apps/language_server/lib/language_server/experimental/protocol/proto/alias.ex diff --git a/apps/language_server/.formatter.exs b/apps/language_server/.formatter.exs index 2cfd23661..f47d45d39 100644 --- a/apps/language_server/.formatter.exs +++ b/apps/language_server/.formatter.exs @@ -4,6 +4,7 @@ impossible_to_format = [ ] proto_dsl = [ + defalias: 1, defenum: 1, defnotification: 2, defnotification: 3, diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto.ex b/apps/language_server/lib/language_server/experimental/protocol/proto.ex index 582779b68..8a38117c9 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto.ex @@ -7,6 +7,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto do alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.LspTypes import ElixirLS.LanguageServer.Experimental.Protocol.Proto.TypeFunctions + import Proto.Alias, only: [defalias: 1] import Proto.Enum, only: [defenum: 1] import Proto.Notification, only: [defnotification: 2, defnotification: 3] import Proto.Request, only: [defrequest: 3] diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/alias.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/alias.ex new file mode 100644 index 000000000..7ba249ce6 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/alias.ex @@ -0,0 +1,22 @@ +defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Alias do + alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.CompileMetadata + + defmacro defalias(alias_definition) do + caller_module = __CALLER__.module + CompileMetadata.add_type_alias_module(caller_module) + + quote location: :keep do + def definition do + unquote(alias_definition) + end + + def __meta__(:type) do + :type_alias + end + + def __meta__(:param_names) do + [] + end + end + end +end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/compile_metadata.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/compile_metadata.ex index 09d93e7bb..b8011396b 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/compile_metadata.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/compile_metadata.ex @@ -5,6 +5,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.CompileMetadata do @notification_modules_key {__MODULE__, :notification_modules} @type_modules_key {__MODULE__, :type_modules} + @type_alias_modules_key {__MODULE__, :type_alias_modules} @request_modules_key {__MODULE__, :request_modules} @response_modules_key {__MODULE__, :response_modules} @@ -20,6 +21,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.CompileMetadata do :persistent_term.get(@response_modules_key, []) end + def type_alias_modules do + :persistent_term.get(@type_alias_modules_key) + end + def type_modules do :persistent_term.get(@type_modules_key) end @@ -40,6 +45,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.CompileMetadata do add_module(@type_modules_key, module) end + def add_type_alias_module(module) do + add_module(@type_alias_modules_key, module) + end + defp update(key, initial_value, update_fn) do case :persistent_term.get(key, :not_found) do :not_found -> :persistent_term.put(key, initial_value) diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/enum.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/enum.ex index 06a816b50..7549d1a89 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/enum.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/enum.ex @@ -22,7 +22,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Enum do for {name, value} <- opts do quote location: :keep do def encode(unquote(name)) do - unquote(value) + {:ok, unquote(value)} end end end @@ -36,6 +36,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Enum do unquote_splicing(encoders) + def encode(val) do + {:error, {:invalid_value, __MODULE__, val}} + end + unquote_splicing(enum_macros) def __meta__(:types) do diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex index c0c02f940..0b3c769fa 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex @@ -51,6 +51,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Field do {:ok, orig_value} end + def extract(:float, _name, orig_value) when is_float(orig_value) do + {:ok, orig_value} + end + def extract(:string, _name, orig_value) when is_binary(orig_value) do {:ok, orig_value} end @@ -59,8 +63,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Field do {:ok, orig_value} end + def extract({:type_alias, alias_module}, name, orig_value) do + extract(alias_module.definition(), name, orig_value) + end + def extract(module, _name, orig_value) - when is_atom(module) and module not in [:integer, :string, :boolean] do + when is_atom(module) and module not in [:integer, :string, :boolean, :float] do module.parse(orig_value) end @@ -103,15 +111,15 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Field do end def encode(:any, field_value) do - field_value + {:ok, field_value} end def encode({:literal, value}, _) do - value + {:ok, value} end def encode({:optional, _}, nil) do - :"$__drop__" + {:ok, :"$__drop__"} end def encode({:optional, field_type}, field_value) do @@ -128,44 +136,99 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Field do end def encode({:list, list_type}, field_value) when is_list(field_value) do - Enum.map(field_value, &encode(list_type, &1)) + encoded = + Enum.reduce_while(field_value, [], fn element, acc -> + case encode(list_type, element) do + {:ok, encoded} -> {:cont, [encoded | acc]} + error -> {:halt, error} + end + end) + + case encoded do + encoded_list when is_list(encoded_list) -> + {:ok, Enum.reverse(encoded_list)} + + error -> + error + end end - def encode(:integer, field_value) do - field_value + def encode(:integer, field_value) when is_integer(field_value) do + {:ok, field_value} + end + + def encode(:integer, string_value) when is_binary(string_value) do + case Integer.parse(string_value) do + {int_value, ""} -> {:ok, int_value} + _ -> {:error, {:invalid_integer, string_value}} + end + end + + def encode(:float, float_value) when is_float(float_value) do + {:ok, float_value} end def encode(:string, field_value) when is_binary(field_value) do - field_value + {:ok, field_value} end def encode(:boolean, field_value) when is_boolean(field_value) do - field_value + {:ok, field_value} end def encode({:map, value_type, _}, field_value) when is_map(field_value) do - Map.new(field_value, fn {k, v} -> {k, encode(value_type, v)} end) + map_fields = + Enum.reduce_while(field_value, [], fn {key, value}, acc -> + case encode(value_type, value) do + {:ok, encoded_value} -> {:cont, [{key, encoded_value} | acc]} + error -> {:halt, error} + end + end) + + case map_fields do + fields when is_list(fields) -> {:ok, Map.new(fields)} + error -> error + end end def encode({:params, param_defs}, field_value) when is_map(field_value) do - Map.new(param_defs, fn {param_name, param_type} -> - {param_name, encode(param_type, Map.get(field_value, param_name))} - end) + param_fields = + Enum.reduce_while(param_defs, [], fn {param_name, param_type}, acc -> + unencoded = Map.get(field_value, param_name) + + case encode(param_type, unencoded) do + {:ok, encoded_value} -> {:cont, [{param_name, encoded_value} | acc]} + error -> {:halt, error} + end + end) + + case param_fields do + fields when is_list(fields) -> {:ok, Map.new(fields)} + error -> error + end end def encode({:constant, constant_module}, field_value) do - constant_module.encode(field_value) + {:ok, constant_module.encode(field_value)} + end + + def encode({:type_alias, alias_module}, field_value) do + encode(alias_module.definition(), field_value) end def encode(module, field_value) when is_atom(module) do if function_exported?(module, :encode, 1) do module.encode(field_value) else - field_value + {:ok, field_value} end end def encode(_, nil) do nil end + + def encode(type, value) do + {:error, {:invalid_type, type, value}} + end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/macros/json.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/json.ex index 1cb318324..0c08f97a8 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/macros/json.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/json.ex @@ -8,7 +8,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Json do encoded_pairs = for {field_name, field_type} <- unquote(dest_module).__meta__(:types), field_value = get_field_value(value, field_name), - encoded_value = Field.encode(field_type, field_value), + {:ok, encoded_value} = Field.encode(field_type, field_value), encoded_value != :"$__drop__" do {field_name, encoded_value} end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/type_functions.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/type_functions.ex index e74f2b209..290ac61ed 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/type_functions.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/type_functions.ex @@ -3,6 +3,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.TypeFunctions do :integer end + def float do + :float + end + def string do :string end @@ -15,6 +19,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.TypeFunctions do :string end + def type_alias(alias_module) do + {:type_alias, alias_module} + end + def literal(what) do {:literal, what} end diff --git a/apps/language_server/test/experimental/protocol/proto_test.exs b/apps/language_server/test/experimental/protocol/proto_test.exs index 9be78be45..4b2e5bf76 100644 --- a/apps/language_server/test/experimental/protocol/proto_test.exs +++ b/apps/language_server/test/experimental/protocol/proto_test.exs @@ -50,6 +50,23 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do end end + describe "float fields" do + defmodule FloatField do + use Proto + deftype float_field: float() + end + + test "can parse a float field" do + assert {:ok, val} = FloatField.parse(%{"floatField" => 494.02}) + assert val.float_field == 494.02 + end + + test "rejects nil float fields" do + assert {:error, {:invalid_value, :float_field, "string"}} = + FloatField.parse(%{"floatField" => "string"}) + end + end + describe "list fields" do defmodule ListField do use Proto @@ -99,6 +116,42 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do end end + describe "type aliases" do + defmodule TypeAlias do + use Proto + defalias one_of([string(), list_of(string())]) + end + + defmodule UsesAlias do + use Proto + + deftype alias: type_alias(TypeAlias), name: string() + end + + test "parses a single item correctly" do + assert {:ok, uses} = UsesAlias.parse(%{"name" => "uses", "alias" => "foo"}) + assert uses.name == "uses" + assert uses.alias == "foo" + end + + test "parses a list correctly" do + assert {:ok, uses} = UsesAlias.parse(%{"name" => "uses", "alias" => ["foo", "bar"]}) + assert uses.name == "uses" + assert uses.alias == ~w(foo bar) + end + + test "encodes correctly" do + assert {:ok, encoded} = encode_and_decode(UsesAlias.new(alias: "hi", name: "easy")) + assert encoded["alias"] == "hi" + assert encoded["name"] == "easy" + end + + test "parse fails if the type isn't correct" do + assert {:error, {:incorrect_type, _, %{}}} = + UsesAlias.parse(%{"name" => "ua", "alias" => %{}}) + end + end + describe "optional fields" do defmodule OptionalString do use Proto From b53ac91ec87e112bcdb9c50d39e4f004b4e32b71 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Wed, 4 Jan 2023 12:12:01 -0800 Subject: [PATCH 15/21] Fixed unit tests When patches are unapplied, getting the beam file returned an empty path charlist, which dialyzer assumed was a real file name due to a weak assumption, which caused unit tests to fail. This was remedied by checking for a non-empty charlist, which allows tests to succeed. Also made patch a test only dependency for .formatter.exs, as this was causing formatters to fail. --- apps/language_server/.formatter.exs | 9 ++++++++- .../lib/language_server/dialyzer/utils.ex | 3 ++- apps/language_server/test/experimental/project_test.exs | 1 + .../test/experimental/server/configuration_test.exs | 5 +++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/language_server/.formatter.exs b/apps/language_server/.formatter.exs index f47d45d39..d95a3b59e 100644 --- a/apps/language_server/.formatter.exs +++ b/apps/language_server/.formatter.exs @@ -3,6 +3,13 @@ impossible_to_format = [ "test/fixtures/project_with_tests/test/error_test.exs" ] +deps = + if Mix.env() == :test do + [:patch] + else + [] + end + proto_dsl = [ defalias: 1, defenum: 1, @@ -14,7 +21,7 @@ proto_dsl = [ ] [ - import_deps: [:patch], + import_deps: deps, export: [ locals_without_parens: proto_dsl ], diff --git a/apps/language_server/lib/language_server/dialyzer/utils.ex b/apps/language_server/lib/language_server/dialyzer/utils.ex index 28b87d318..95af5c274 100644 --- a/apps/language_server/lib/language_server/dialyzer/utils.ex +++ b/apps/language_server/lib/language_server/dialyzer/utils.ex @@ -4,13 +4,14 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Utils do @spec dialyzable?(module()) :: boolean() def dialyzable?(module) do file = get_beam_file(module) + is_list(file) and match?({:ok, _}, :dialyzer_utils.get_core_from_beam(file)) end @spec get_beam_file(module()) :: charlist() | :preloaded | :non_existing | :cover_compiled def get_beam_file(module) do case :code.which(module) do - file when is_list(file) -> + [_ | _] = file -> file other -> diff --git a/apps/language_server/test/experimental/project_test.exs b/apps/language_server/test/experimental/project_test.exs index a00e61f64..6b6c31cd9 100644 --- a/apps/language_server/test/experimental/project_test.exs +++ b/apps/language_server/test/experimental/project_test.exs @@ -137,6 +137,7 @@ defmodule ElixirLS.Experimental.ProjectTest do def with_patched_system_put_env(_) do patch(System, :put_env, :ok) + on_exit(fn -> restore(System) end) :ok end diff --git a/apps/language_server/test/experimental/server/configuration_test.exs b/apps/language_server/test/experimental/server/configuration_test.exs index 394c18428..c96c64588 100644 --- a/apps/language_server/test/experimental/server/configuration_test.exs +++ b/apps/language_server/test/experimental/server/configuration_test.exs @@ -149,6 +149,11 @@ defmodule ElixirLS.Experimental.Server.ConfigurationTest do def with_patched_system_put_env(_) do patch(System, :put_env, :ok) + + on_exit(fn -> + restore(System) + end) + :ok end From 7fde975203bb338c0786bd144a4f3a173ecbec1e Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Wed, 4 Jan 2023 22:02:45 -0700 Subject: [PATCH 16/21] removed unused module attribute --- apps/language_server/lib/language_server.ex | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/language_server/lib/language_server.ex b/apps/language_server/lib/language_server.ex index 140602269..04c5cd3d5 100644 --- a/apps/language_server/lib/language_server.ex +++ b/apps/language_server/lib/language_server.ex @@ -7,9 +7,6 @@ defmodule ElixirLS.LanguageServer do alias ElixirLS.LanguageServer alias ElixirLS.LanguageServer.Experimental - # @maybe_experimental_server [Experimental.Server] - @maybe_experimental_server [] - @impl Application def start(_type, _args) do Experimental.LanguageServer.persist_enabled_state() From 07a4be8ee74b7c573c7b9a1db9b3e78dbe7786b8 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Thu, 19 Jan 2023 11:14:51 -0800 Subject: [PATCH 17/21] Added sourceror to ease ast to string conversion Under 1.12, Macro.to_string proudces wonky output, making `def` calls look like function calls by adding needless parenthesis. These parenthesis throw off the diff algorithm, and caused an off-by-one error in the code mod. Sourceror has backported the newer code generation so that it's compatible all the way back to 1.10, and produces the correct output. --- .../experimental/code_mod/replace_with_underscore.ex | 2 +- apps/language_server/mix.exs | 1 + mix.lock | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex index ba44fc387..d385fcf67 100644 --- a/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex @@ -37,7 +37,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do other -> other end) - |> Macro.to_string() + |> Sourceror.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 diff --git a/apps/language_server/mix.exs b/apps/language_server/mix.exs index f1368ff08..5982fe99e 100644 --- a/apps/language_server/mix.exs +++ b/apps/language_server/mix.exs @@ -29,6 +29,7 @@ defmodule ElixirLS.LanguageServer.Mixfile do {:elixir_ls_utils, in_umbrella: true}, {:elixir_sense, github: "elixir-lsp/elixir_sense"}, {:erl2ex, github: "dazuma/erl2ex"}, + {:sourceror, "0.11.2"}, {:dialyxir_vendored, github: "elixir-lsp/dialyxir", branch: "vendored", runtime: false}, {:jason_vendored, github: "elixir-lsp/jason", branch: "vendored"}, {:stream_data, "~> 0.5", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 28315ec04..e902b090c 100644 --- a/mix.lock +++ b/mix.lock @@ -10,6 +10,7 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, "patch": {:hex, :patch, "0.12.0", "2da8967d382bade20344a3e89d618bfba563b12d4ac93955468e830777f816b0", [:mix], [], "hexpm", "ffd0e9a7f2ad5054f37af84067ee88b1ad337308a1cb227e181e3967127b0235"}, "path_glob_vendored": {:git, "https://github.com/elixir-lsp/path_glob.git", "965350dc41def7be4a70a23904195c733a2ecc84", [branch: "vendored"]}, + "sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"}, "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, } From b23a87dedebde7b2d0dee86a22adc1931f6a544a Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Thu, 19 Jan 2023 11:44:25 -0800 Subject: [PATCH 18/21] Added patch as a dev dependency Patch's assertions will fail in CI due to `mix format --check-formatted` running in dev. Importing patch's deps in test will fix this. --- apps/language_server/.formatter.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/language_server/.formatter.exs b/apps/language_server/.formatter.exs index d95a3b59e..9e7318862 100644 --- a/apps/language_server/.formatter.exs +++ b/apps/language_server/.formatter.exs @@ -4,7 +4,7 @@ impossible_to_format = [ ] deps = - if Mix.env() == :test do + if Mix.env() in [:dev, :test] do [:patch] else [] From 6c8e9d8f979fe5577c5ca471fa1992265d73f6b3 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Thu, 19 Jan 2023 11:53:00 -0800 Subject: [PATCH 19/21] Run check formatted in test so patch assertions work --- .github/workflows/ci.yml | 4 ++-- apps/language_server/.formatter.exs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31e696b04..eaae286dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,6 @@ jobs: mix deps.get - name: Restore timestamps to prevent unnecessary recompilation run: IFS=$'\n'; for f in $(git ls-files); do touch -d "$(git log -n 1 --pretty='%cI' -- $f)" "$f"; done - - run: mix format --check-formatted - - run: cd apps/language_server && mix format --check-formatted + - run: MIX_ENV=test mix format --check-formatted + - run: cd apps/language_server && MIX_ENV=test mix format --check-formatted - run: mix dialyzer_vendored diff --git a/apps/language_server/.formatter.exs b/apps/language_server/.formatter.exs index 9e7318862..d95a3b59e 100644 --- a/apps/language_server/.formatter.exs +++ b/apps/language_server/.formatter.exs @@ -4,7 +4,7 @@ impossible_to_format = [ ] deps = - if Mix.env() in [:dev, :test] do + if Mix.env() == :test do [:patch] else [] From 32f9fffd45cb0c17a804453b4b06b5c3faa1fe30 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Thu, 19 Jan 2023 14:02:33 -0800 Subject: [PATCH 20/21] Fixed dialyzer errors --- .../language_server/experimental/code_mod/format.ex | 3 ++- .../experimental/code_mod/replace_with_underscore.ex | 2 +- .../lib/language_server/experimental/project.ex | 12 +++++------- .../experimental/protocol/responses.ex | 2 ++ .../provider/code_action/replace_with_underscore.ex | 2 +- .../lib/language_server/experimental/server.ex | 3 +-- .../experimental/server/configuration.ex | 4 +++- .../lib/language_server/experimental/server/state.ex | 2 ++ .../lib/language_server/experimental/source_file.ex | 3 ++- .../language_server/experimental/source_file/line.ex | 2 ++ 10 files changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/language_server/lib/language_server/experimental/code_mod/format.ex b/apps/language_server/lib/language_server/experimental/code_mod/format.ex index 4fc0ef6db..7060d3774 100644 --- a/apps/language_server/lib/language_server/experimental/code_mod/format.ex +++ b/apps/language_server/lib/language_server/experimental/code_mod/format.ex @@ -44,7 +44,8 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Format do |> formatter.() end - @spec formatter_for(String.t()) :: {:ok, formatter_function, keyword()} | :error + @spec formatter_for(String.t()) :: + {:ok, formatter_function, keyword()} | {:error, :no_formatter_available} defp formatter_for(uri_or_path) do path = Conversions.ensure_path(uri_or_path) diff --git a/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex index d385fcf67..0e2da1830 100644 --- a/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex @@ -1,7 +1,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do - alias ElixirLS.LanguageServer.Protocol.TextEdit alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit @spec text_edits(String.t(), Ast.t(), String.t() | atom) :: {:ok, [TextEdit.t()]} | :error def text_edits(original_text, ast, variable_name) do diff --git a/apps/language_server/lib/language_server/experimental/project.ex b/apps/language_server/lib/language_server/experimental/project.ex index fc529f39e..7136a2c65 100644 --- a/apps/language_server/lib/language_server/experimental/project.ex +++ b/apps/language_server/lib/language_server/experimental/project.ex @@ -19,16 +19,16 @@ defmodule ElixirLS.LanguageServer.Experimental.Project do @type message :: String.t() @type restart_notification :: {:restart, Logger.level(), String.t()} @type t :: %__MODULE__{ - root_uri: LanguageServer.uri(), - working_uri: LanguageServer.uri(), - mix_exs_uri: LanguageServer.uri(), + root_uri: LanguageServer.uri() | nil, + working_uri: LanguageServer.uri() | nil, + mix_exs_uri: LanguageServer.uri() | nil, mix_env: atom(), mix_target: atom(), - env_variables: %{String.t() => String.t()} + env_variables: %{String.t() => String.t()} | nil } @type error_with_message :: {:error, message} # Public - @spec new(LanguageServer.uri()) :: t + @spec new(LanguageServer.uri() | nil) :: t def new(root_uri) do maybe_set_root_uri(%__MODULE__{}, root_uri) end @@ -254,8 +254,6 @@ defmodule ElixirLS.LanguageServer.Experimental.Project do end end - defp mix_exs_exists?(nil), do: false - defp mix_exs_exists?(mix_exs_path) do File.exists?(mix_exs_path) end diff --git a/apps/language_server/lib/language_server/experimental/protocol/responses.ex b/apps/language_server/lib/language_server/experimental/protocol/responses.ex index b7133216e..beebeac88 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/responses.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/responses.ex @@ -19,4 +19,6 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Responses do defresponse optional(list_of(Types.CodeAction)) end + + @type response :: FindReferences.t() | CodeAction.t() | Formatting.t() end diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex index 4ef868c35..1abe91a82 100644 --- a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex @@ -12,7 +12,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUn alias ElixirLS.LanguageServer.Experimental.Protocol.Types.WorkspaceEdit alias ElixirLS.LanguageServer.Experimental.SourceFile - @spec apply(CodeAction.t()) :: [CodeActionReply.t()] + @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]) || [] diff --git a/apps/language_server/lib/language_server/experimental/server.ex b/apps/language_server/lib/language_server/experimental/server.ex index cef57efb1..b1c285c74 100644 --- a/apps/language_server/lib/language_server/experimental/server.ex +++ b/apps/language_server/lib/language_server/experimental/server.ex @@ -74,7 +74,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Server do "Using default settings." ) - {:ok, config} = State.default_configuration(state) + {:ok, config} = State.default_configuration(state.configuration) {:noreply, %State{state | configuration: config}} end @@ -114,7 +114,6 @@ defmodule ElixirLS.LanguageServer.Experimental.Server do defp apply_to_state(%State{} = state, %{} = request_or_notification) do case State.apply(state, request_or_notification) do {:ok, new_state} -> {:ok, new_state} - :ok -> {:ok, state} error -> {error, state} end end diff --git a/apps/language_server/lib/language_server/experimental/server/configuration.ex b/apps/language_server/lib/language_server/experimental/server/configuration.ex index 11931fce3..192700a9c 100644 --- a/apps/language_server/lib/language_server/experimental/server/configuration.ex +++ b/apps/language_server/lib/language_server/experimental/server/configuration.ex @@ -25,8 +25,9 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.Configuration do @spec default(t) :: {:ok, t} - | {:ok, t, Requests.RegisterCapabilities.t()} + | {:ok, t, Requests.RegisterCapability.t()} | {:restart, Logger.level(), String.t()} + | {:error, String.t()} def default(%__MODULE__{} = config) do apply_config_change(config, default_config()) end @@ -35,6 +36,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.Configuration do {:ok, t} | {:ok, t, Requests.RegisterCapability.t()} | {:restart, Logger.level(), String.t()} + | {:error, String.t()} def on_change(%__MODULE__{} = old_config, :defaults) do apply_config_change(old_config, default_config()) end diff --git a/apps/language_server/lib/language_server/experimental/server/state.ex b/apps/language_server/lib/language_server/experimental/server/state.ex index 970f3cb93..d4cb79ddd 100644 --- a/apps/language_server/lib/language_server/experimental/server/state.ex +++ b/apps/language_server/lib/language_server/experimental/server/state.ex @@ -18,6 +18,8 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.State do defstruct configuration: nil, initialized?: false + @type t :: %__MODULE__{} + def new do %__MODULE__{} end diff --git a/apps/language_server/lib/language_server/experimental/source_file.ex b/apps/language_server/lib/language_server/experimental/source_file.ex index d855872a3..b45f428df 100644 --- a/apps/language_server/lib/language_server/experimental/source_file.ex +++ b/apps/language_server/lib/language_server/experimental/source_file.ex @@ -3,6 +3,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions alias ElixirLS.LanguageServer.Experimental.SourceFile.Document + alias ElixirLS.LanguageServer.Experimental.SourceFile.Line alias ElixirLS.LanguageServer.Experimental.SourceFile.Position alias ElixirLS.LanguageServer.Experimental.SourceFile.Range alias ElixirLS.LanguageServer.SourceFile @@ -22,7 +23,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do @type version :: pos_integer() @type change_application_error :: {:error, {:invalid_range, map()}} # public - @spec new(URI.t(), String.t(), pos_integer()) :: t + def new(uri, text, version) do uri = Conversions.ensure_uri(uri) diff --git a/apps/language_server/lib/language_server/experimental/source_file/line.ex b/apps/language_server/lib/language_server/experimental/source_file/line.ex index 4494fd0b1..75e313d4b 100644 --- a/apps/language_server/lib/language_server/experimental/source_file/line.ex +++ b/apps/language_server/lib/language_server/experimental/source_file/line.ex @@ -2,4 +2,6 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Line do import Record defrecord :line, text: nil, ending: nil, line_number: 0, ascii?: true + + @type t :: tuple() end From f33cb191b65f5842f726320f226a43d6e8e057a3 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Thu, 19 Jan 2023 22:24:24 -0800 Subject: [PATCH 21/21] Encapsulated sourceror --- .../experimental/code_mod/ast.ex | 17 +++++++++++++---- .../code_mod/replace_with_underscore.ex | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/language_server/lib/language_server/experimental/code_mod/ast.ex b/apps/language_server/lib/language_server/experimental/code_mod/ast.ex index b5172d301..a7ddfd3af 100644 --- a/apps/language_server/lib/language_server/experimental/code_mod/ast.ex +++ b/apps/language_server/lib/language_server/experimental/code_mod/ast.ex @@ -1,8 +1,16 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Ast do alias ElixirLS.LanguageServer.Experimental.SourceFile - @type t :: any() + @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) :: t def from(%SourceFile{} = source_file) do source_file |> SourceFile.to_string() @@ -10,10 +18,11 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Ast do end def from(s) when is_binary(s) do - parse(s) + ElixirSense.string_to_quoted(s, 1, 6, token_metadata: true) end - defp parse(s) when is_binary(s) do - ElixirSense.string_to_quoted(s, 1, 6, token_metadata: true) + @spec to_string(t()) :: String.t() + def to_string(ast) do + Sourceror.to_string(ast) end end diff --git a/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex index 0e2da1830..bbb84bad7 100644 --- a/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex +++ b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex @@ -37,7 +37,7 @@ defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do other -> other end) - |> Sourceror.to_string() + |> 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