From d8b96940ea93fc5b3c77a8b25e62454101bb30f0 Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Fri, 28 Oct 2022 09:41:09 -0700 Subject: [PATCH] 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 | 45 ++- .../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 | 31 +- .../experimental/source_file/conversions.ex | 77 ++--- .../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 | 72 +++- .../test/support/fixtures/lsp_protocol.ex | 14 +- config/config.exs | 12 + 57 files changed, 3552 insertions(+), 158 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 b21701760..04c5cd3d5 100644 --- a/apps/language_server/lib/language_server.ex +++ b/apps/language_server/lib/language_server.ex @@ -9,17 +9,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, 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) @@ -38,4 +40,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 ecac5a22c..7bb825ce2 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 @@ -163,7 +171,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do defp apply_change( %__MODULE__{} = source, %{ - "range" => range(start_line, _, end_line, _) = range, + "range" => range(start_line, _, end_line, _), "text" => new_text } ) @@ -177,7 +185,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do defp apply_change( %__MODULE__{} = source, %{ - "range" => range(start_line, start_char, end_line, end_char) = range, + "range" => range(start_line, start_char, end_line, end_char), "text" => new_text } ) @@ -265,15 +273,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 032985607..b34e25018 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 @@ -77,6 +88,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 @@ -95,59 +120,17 @@ 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) - elixir_character = - utf16_line - |> binary_part(0, min(lsp_character * 2, byte_size)) - |> to_utf8() - |> byte_size() - - {:ok, elixir_character} - end - - def elixir_character_to_lsp(utf8_line, elixir_character) do - case utf8_line |> String.slice(0..(elixir_character - 2)) |> to_utf16() do - {:ok, utf16_line} -> {:ok, div(byte_size(utf16_line), 2)} - error -> error - end + {:ok, position} 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 - 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 - end + # Private defp extract_lsp_character(%ElixirPosition{} = position, line(ascii?: true)) do {:ok, position.character} end defp extract_lsp_character(%ElixirPosition{} = position, line(text: utf8_text)) do - with {:ok, utf16_txt} <- to_utf16(utf8_text) do - elixir_character_to_lsp(utf16_txt, position.character) - end + {:ok, CodeUnit.utf16_offset(utf8_text, position.character)} end defp extract_elixir_character(%LSPosition{} = position, line(ascii?: true)) do @@ -155,8 +138,6 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do end defp extract_elixir_character(%LSPosition{} = position, line(text: utf8_text)) do - with {:ok, utf16_text} <- to_utf16(utf8_text) do - lsp_character_to_elixir(utf16_text, position.character) - end + {: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 00d99e74c..c7feab732 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()) :: SourceFile.t() 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 900d04e40..4f60e5cb7 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.{ @@ -169,29 +170,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} @@ -515,7 +544,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 95f574088..30b131cfe 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 db3ed2907..d33a0b36d 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 2893fe0df..cf5cd0bdd 100644 --- a/apps/language_server/test/experimental/source_file_test.exs +++ b/apps/language_server/test/experimental/source_file_test.exs @@ -1,13 +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 ExUnit.CaptureIO import ElixirLS.LanguageServer.Experimental.SourceFile, except: [to_string: 1] test "format_spec/2 with nil" do @@ -606,6 +607,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" 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")