diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31e696b04..eaae286dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,6 @@ jobs: mix deps.get - name: Restore timestamps to prevent unnecessary recompilation run: IFS=$'\n'; for f in $(git ls-files); do touch -d "$(git log -n 1 --pretty='%cI' -- $f)" "$f"; done - - run: mix format --check-formatted - - run: cd apps/language_server && mix format --check-formatted + - run: MIX_ENV=test mix format --check-formatted + - run: cd apps/language_server && MIX_ENV=test mix format --check-formatted - run: mix dialyzer_vendored diff --git a/apps/language_server/.formatter.exs b/apps/language_server/.formatter.exs index 950259bb6..d95a3b59e 100644 --- a/apps/language_server/.formatter.exs +++ b/apps/language_server/.formatter.exs @@ -3,15 +3,25 @@ impossible_to_format = [ "test/fixtures/project_with_tests/test/error_test.exs" ] +deps = + if Mix.env() == :test do + [:patch] + else + [] + end + proto_dsl = [ + defalias: 1, defenum: 1, defnotification: 2, - defrequest: 2, + defnotification: 3, + defrequest: 3, defresponse: 1, deftype: 1 ] [ + import_deps: deps, export: [ locals_without_parens: proto_dsl ], diff --git a/apps/language_server/lib/language_server.ex b/apps/language_server/lib/language_server.ex index 2c5891023..04c5cd3d5 100644 --- a/apps/language_server/lib/language_server.ex +++ b/apps/language_server/lib/language_server.ex @@ -7,23 +7,21 @@ defmodule ElixirLS.LanguageServer do alias ElixirLS.LanguageServer alias ElixirLS.LanguageServer.Experimental - # @maybe_experimental_server [Experimental.Server] - @maybe_experimental_server [] - @impl Application def start(_type, _args) do - children = [ - Experimental.SourceFile.Store, - {ElixirLS.LanguageServer.Server, ElixirLS.LanguageServer.Server}, - Experimental.Server, - {ElixirLS.LanguageServer.PacketRouter, - [LanguageServer.Server] ++ @maybe_experimental_server}, - {ElixirLS.LanguageServer.JsonRpc, - name: ElixirLS.LanguageServer.JsonRpc, language_server: LanguageServer.PacketRouter}, - {ElixirLS.LanguageServer.Providers.WorkspaceSymbols, []}, - {ElixirLS.LanguageServer.Tracer, []}, - {ElixirLS.LanguageServer.ExUnitTestTracer, []} - ] + Experimental.LanguageServer.persist_enabled_state() + + children = + [ + maybe_experimental_supervisor(), + {ElixirLS.LanguageServer.Server, ElixirLS.LanguageServer.Server}, + maybe_packet_router(), + jsonrpc(), + {ElixirLS.LanguageServer.Providers.WorkspaceSymbols, []}, + {ElixirLS.LanguageServer.Tracer, []}, + {ElixirLS.LanguageServer.ExUnitTestTracer, []} + ] + |> Enum.reject(&is_nil/1) opts = [strategy: :one_for_one, name: LanguageServer.Supervisor, max_restarts: 0] Supervisor.start_link(children, opts) @@ -42,4 +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/dialyzer/utils.ex b/apps/language_server/lib/language_server/dialyzer/utils.ex index 28b87d318..95af5c274 100644 --- a/apps/language_server/lib/language_server/dialyzer/utils.ex +++ b/apps/language_server/lib/language_server/dialyzer/utils.ex @@ -4,13 +4,14 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Utils do @spec dialyzable?(module()) :: boolean() def dialyzable?(module) do file = get_beam_file(module) + is_list(file) and match?({:ok, _}, :dialyzer_utils.get_core_from_beam(file)) end @spec get_beam_file(module()) :: charlist() | :preloaded | :non_existing | :cover_compiled def get_beam_file(module) do case :code.which(module) do - file when is_list(file) -> + [_ | _] = file -> file other -> diff --git a/apps/language_server/lib/language_server/experimental/code_mod/ast.ex b/apps/language_server/lib/language_server/experimental/code_mod/ast.ex new file mode 100644 index 000000000..a7ddfd3af --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/code_mod/ast.ex @@ -0,0 +1,28 @@ +defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Ast do + alias ElixirLS.LanguageServer.Experimental.SourceFile + + @type source :: SourceFile.t() | String.t() + @type t :: + atom() + | binary() + | [any()] + | number() + | {any(), any()} + | {atom() | {any(), [any()], atom() | [any()]}, Keyword.t(), atom() | [any()]} + + @spec from(source) :: t + def from(%SourceFile{} = source_file) do + source_file + |> SourceFile.to_string() + |> from() + end + + def from(s) when is_binary(s) do + ElixirSense.string_to_quoted(s, 1, 6, token_metadata: true) + end + + @spec to_string(t()) :: String.t() + def to_string(ast) do + Sourceror.to_string(ast) + end +end diff --git a/apps/language_server/lib/language_server/experimental/code_mod/diff.ex b/apps/language_server/lib/language_server/experimental/code_mod/diff.ex new file mode 100644 index 000000000..94395095a --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/code_mod/diff.ex @@ -0,0 +1,106 @@ +defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Diff do + alias ElixirLS.LanguageServer.Experimental.CodeUnit + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Position + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range + 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 + {_, {current_line, prev_lines}} = + Enum.reduce(difference, {{0, 0}, {[], []}}, fn + {diff_type, diff_string}, {position, edits} -> + apply_diff(diff_type, position, diff_string, edits) + end) + + [current_line | prev_lines] + |> Enum.flat_map(fn line_edits -> + line_edits + |> Enum.reduce([], &collapse/2) + |> Enum.reverse() + end) + end + + # This collapses a delete and an 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 + advance(doc_string, position, edits) + end + + defp apply_diff(:del, {line, code_unit} = position, change, edits) do + {after_pos, {current_line, prev_lines}} = advance(change, position, edits) + {edit_end_line, edit_end_unit} = after_pos + current_line = [edit("", line, code_unit, edit_end_line, edit_end_unit) | current_line] + {after_pos, {current_line, prev_lines}} + end + + defp apply_diff(:ins, {line, code_unit} = position, change, {current_line, prev_lines}) do + current_line = [edit(change, line, code_unit, line, code_unit) | current_line] + advance(change, position, {current_line, prev_lines}) + end + + defp advance(<<>>, position, edits) do + {position, edits} + end + + for ending <- ["\r\n", "\r", "\n"] do + defp advance(<>, {line, _unit}, {current_line, prev_lines}) do + edits = {[], [current_line | prev_lines]} + advance(rest, {line + 1, 0}, edits) + end + end + + defp advance(<>, {line, unit}, edits) when c < 128 do + advance(rest, {line, unit + 1}, edits) + end + + defp advance(<>, {line, unit}, edits) do + increment = CodeUnit.count(:utf16, <>) + advance(rest, {line, unit + increment}, edits) + end + + defp edit(text, start_line, start_unit, end_line, end_unit) do + TextEdit.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/code_mod/format.ex b/apps/language_server/lib/language_server/experimental/code_mod/format.ex new file mode 100644 index 000000000..7060d3774 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/code_mod/format.ex @@ -0,0 +1,159 @@ +defmodule ElixirLS.LanguageServer.Experimental.CodeMod.Format do + alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit + + 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, :no_formatter_available} + 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/code_mod/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex new file mode 100644 index 000000000..bbb84bad7 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex @@ -0,0 +1,74 @@ +defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore do + alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast + alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit + + @spec text_edits(String.t(), Ast.t(), String.t() | atom) :: {:ok, [TextEdit.t()]} | :error + def text_edits(original_text, ast, variable_name) do + variable_name = ensure_atom(variable_name) + + with {:ok, transformed} <- apply_transform(original_text, ast, variable_name) do + {:ok, to_text_edits(original_text, transformed)} + end + end + + defp to_text_edits(orig_text, fixed_text) do + orig_text + |> Diff.diff(fixed_text) + |> Enum.filter(&(&1.new_text == "_")) + end + + defp ensure_atom(variable_name) when is_binary(variable_name) do + String.to_atom(variable_name) + end + + defp ensure_atom(variable_name) when is_atom(variable_name) do + variable_name + end + + defp apply_transform(line_text, quoted_ast, unused_variable_name) do + underscored_variable_name = :"_#{unused_variable_name}" + leading_indent = leading_indent(line_text) + + Macro.postwalk(quoted_ast, fn + {^unused_variable_name, meta, context} -> + {underscored_variable_name, meta, context} + + other -> + other + end) + |> Ast.to_string() + # We're dealing with a single error on a single line. + # If the line doesn't compile (like it has a do with no end), ElixirSense + # adds additional lines do documents with errors, so take the first line, as it's + # the properly transformed source + |> fetch_line(0) + |> case do + {:ok, text} -> + {:ok, "#{leading_indent}#{text}"} + + error -> + error + end + end + + @indent_regex ~r/^\s+/ + defp leading_indent(line_text) do + case Regex.scan(@indent_regex, line_text) do + [indent] -> indent + _ -> "" + end + end + + defp fetch_line(message, line_number) do + line = + message + |> String.split(["\r\n", "\r", "\n"]) + |> Enum.at(line_number) + + case line do + nil -> :error + other -> {:ok, other} + end + end +end diff --git a/apps/language_server/lib/language_server/experimental/code_unit.ex b/apps/language_server/lib/language_server/experimental/code_unit.ex new file mode 100644 index 000000000..96ab4a658 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/code_unit.ex @@ -0,0 +1,180 @@ +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, 0) + end + + @spec to_utf16(String.t(), utf8_code_unit()) :: {:ok, utf16_code_unit()} | error + def to_utf16(binary, utf16_unit) do + do_to_utf16(binary, utf16_unit, 0) + end + + def count(:utf16, binary) do + do_count_utf16(binary, 0) + end + + # Private + + # UTF-16 + + def do_count_utf16(<<>>, count) do + count + end + + def do_count_utf16(<>, count) when c < 128 do + do_count_utf16(rest, count + 1) + end + + def do_count_utf16(<>, count) do + increment = + <> + |> byte_size() + |> div(2) + + do_count_utf16(rest, count + increment) + end + + defp do_utf16_offset(_, 0, offset) do + offset + end + + defp do_utf16_offset(<<>>, _, offset) do + # this clause pegs the offset at the end of the string + # no matter the character index + offset + end + + defp do_utf16_offset(<>, remaining, offset) when c < 128 do + do_utf16_offset(rest, remaining - 1, offset + 1) + end + + defp do_utf16_offset(<>, remaining, offset) do + s = <> + increment = utf16_size(s) + do_utf16_offset(rest, remaining - 1, offset + increment) + end + + defp do_to_utf16(_, 0, utf16_unit) do + {:ok, utf16_unit} + end + + defp do_to_utf16(_, utf8_unit, _) when utf8_unit < 0 do + {:error, :misaligned} + end + + defp do_to_utf16(<<>>, _remaining, _utf16_unit) do + {:error, :out_of_bounds} + end + + defp do_to_utf16(<>, utf8_unit, utf16_unit) when c < 128 do + do_to_utf16(rest, utf8_unit - 1, utf16_unit + 1) + end + + defp do_to_utf16(<>, utf8_unit, utf16_unit) do + utf8_string = <> + increment = utf16_size(utf8_string) + decrement = byte_size(utf8_string) + + do_to_utf16(rest, utf8_unit - decrement, utf16_unit + increment) + end + + defp utf16_size(binary) when is_binary(binary) do + binary + |> :unicode.characters_to_binary(:utf8, :utf16) + |> byte_size() + |> div(2) + end + + # UTF-8 + + defp do_utf8_offset(_, 0, offset) do + offset + end + + defp do_utf8_offset(<<>>, _, offset) do + # this clause pegs the offset at the end of the string + # no matter the character index + offset + end + + defp do_utf8_offset(<>, remaining, offset) when c < 128 do + do_utf8_offset(rest, remaining - 1, offset + 1) + end + + defp do_utf8_offset(<>, remaining, offset) do + s = <> + increment = utf8_size(s) + decrement = utf16_size(s) + do_utf8_offset(rest, remaining - decrement, offset + increment) + end + + defp do_to_utf8(_, 0, utf8_unit) do + {:ok, utf8_unit} + end + + defp do_to_utf8(_, utf_16_units, _) when utf_16_units < 0 do + {:error, :misaligned} + end + + defp do_to_utf8(<<>>, _remaining, _utf8_unit) do + {:error, :out_of_bounds} + end + + defp do_to_utf8(<>, utf16_unit, utf8_unit) when c < 128 do + do_to_utf8(rest, utf16_unit - 1, utf8_unit + 1) + end + + defp do_to_utf8(<>, utf16_unit, utf8_unit) do + utf8_code_units = byte_size(<>) + utf16_code_units = utf16_size(<>) + + do_to_utf8(rest, utf16_unit - utf16_code_units, utf8_unit + utf8_code_units) + end + + defp utf8_size(binary) when is_binary(binary) do + byte_size(binary) + end +end diff --git a/apps/language_server/lib/language_server/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..7136a2c65 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/project.ex @@ -0,0 +1,260 @@ +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() | nil, + working_uri: LanguageServer.uri() | nil, + mix_exs_uri: LanguageServer.uri() | nil, + mix_env: atom(), + mix_target: atom(), + env_variables: %{String.t() => String.t()} | nil + } + @type error_with_message :: {:error, message} + # Public + @spec new(LanguageServer.uri() | nil) :: 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?(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..c4604a71d 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/notifications.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/notifications.ex @@ -2,28 +2,33 @@ 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 @@ -31,19 +36,28 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Notifications do 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 + + defmodule PublishDiagnostics do + use Proto + + defnotification "textDocument/publishDiagnostics", :shared, + uri: string(), + version: optional(integer()), + diagnostics: list_of(Types.Diagnostic) end use Proto, decoders: :notifications diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto.ex b/apps/language_server/lib/language_server/experimental/protocol/proto.ex index 382d6e902..8a38117c9 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,12 @@ 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.Alias, only: [defalias: 1] 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/alias.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/alias.ex new file mode 100644 index 000000000..7ba249ce6 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/alias.ex @@ -0,0 +1,22 @@ +defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Alias do + alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.CompileMetadata + + defmacro defalias(alias_definition) do + caller_module = __CALLER__.module + CompileMetadata.add_type_alias_module(caller_module) + + quote location: :keep do + def definition do + unquote(alias_definition) + end + + def __meta__(:type) do + :type_alias + end + + def __meta__(:param_names) do + [] + end + end + end +end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/compile_metadata.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/compile_metadata.ex index 09d93e7bb..b8011396b 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/compile_metadata.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/compile_metadata.ex @@ -5,6 +5,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.CompileMetadata do @notification_modules_key {__MODULE__, :notification_modules} @type_modules_key {__MODULE__, :type_modules} + @type_alias_modules_key {__MODULE__, :type_alias_modules} @request_modules_key {__MODULE__, :request_modules} @response_modules_key {__MODULE__, :response_modules} @@ -20,6 +21,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.CompileMetadata do :persistent_term.get(@response_modules_key, []) end + def type_alias_modules do + :persistent_term.get(@type_alias_modules_key) + end + def type_modules do :persistent_term.get(@type_modules_key) end @@ -40,6 +45,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.CompileMetadata do add_module(@type_modules_key, module) end + def add_type_alias_module(module) do + add_module(@type_alias_modules_key, module) + end + defp update(key, initial_value, update_fn) do case :persistent_term.get(key, :not_found) do :not_found -> :persistent_term.put(key, initial_value) diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex index df4939f79..10afa6873 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/convert.ex @@ -1,51 +1,96 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert do - alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions + alias ElixirLS.LanguageServer.SourceFile alias ElixirLS.LanguageServer.Experimental.Protocol.Types + alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions alias ElixirLS.LanguageServer.Experimental.SourceFile - def to_elixir(%{text_document: _} = request) do - with {:ok, source_file} <- fetch_source_file(request.lsp), - {:ok, updates} <- convert(request.lsp, source_file) do + def to_elixir(%{lsp: lsp_request} = request) do + with {:ok, elixir_request, source_file} <- convert(lsp_request) do updated_request = - request - |> Map.put(:source_file, source_file) - |> Map.merge(updates) + case Map.merge(request, Map.from_struct(elixir_request)) do + %_{source_file: _} = updated -> Map.put(updated, :source_file, source_file) + updated -> updated + end {:ok, updated_request} end end + def to_elixir(%_request_module{lsp: lsp_request} = request) do + converted = Map.merge(request, Map.from_struct(lsp_request)) + {:ok, converted} + end + def to_elixir(request) do request = Map.merge(request, Map.from_struct(request.lsp)) {: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 - {:ok, %{range: ex_range}} + defp convert(%_{text_document: _} = request) do + with {:ok, source_file} <- fetch_source_file(request), + {:ok, converted} <- convert(request, source_file) do + {:ok, converted, source_file} end end - defp convert(%{position: position}, source_file) do - with {:ok, ex_pos} <- Conversions.to_elixir(position, source_file) do - {:ok, %{position: ex_pos}} - end + defp convert(%_{} = request) do + {:ok, request, nil} + end + + defp convert(%Types.Range{} = range, %SourceFile{} = source_file) do + Conversions.to_elixir(range, source_file) + end + + defp convert(%Types.Position{} = pos, %SourceFile{} = source_file) do + Conversions.to_elixir(pos, source_file) + end + + defp convert(%_struct{} = request, %SourceFile{} = source_file) do + kvps = + request + |> Map.from_struct() + |> Enum.reduce(request, fn {key, value}, request -> + {:ok, value} = convert(value, source_file) + Map.put(request, key, value) + end) + + {:ok, Map.merge(request, kvps)} + end + + defp convert(list, %SourceFile{} = source_file) when is_list(list) do + items = + Enum.map(list, fn item -> + {:ok, item} = convert(item, source_file) + item + end) + + {:ok, items} + end + + defp convert(%{} = map, %SourceFile{} = source_file) do + converted = + Map.new(map, fn {k, v} -> + {:ok, converted} = convert(v, source_file) + {k, converted} + end) + + {:ok, converted} end - defp convert(_, _) do - {:ok, %{}} + defp convert(item, %SourceFile{} = _) do + {:ok, item} end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/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/enum.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/enum.ex index 06a816b50..7549d1a89 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/enum.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/enum.ex @@ -22,7 +22,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Enum do for {name, value} <- opts do quote location: :keep do def encode(unquote(name)) do - unquote(value) + {:ok, unquote(value)} end end end @@ -36,6 +36,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Enum do unquote_splicing(encoders) + def encode(val) do + {:error, {:invalid_value, __MODULE__, val}} + end + unquote_splicing(enum_macros) def __meta__(:types) do diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex index f0459f5be..0b3c769fa 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/field.ex @@ -51,6 +51,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Field do {:ok, orig_value} end + def extract(:float, _name, orig_value) when is_float(orig_value) do + {:ok, orig_value} + end + def extract(:string, _name, orig_value) when is_binary(orig_value) do {:ok, orig_value} end @@ -59,8 +63,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Field do {:ok, orig_value} end + def extract({:type_alias, alias_module}, name, orig_value) do + extract(alias_module.definition(), name, orig_value) + end + def extract(module, _name, orig_value) - when is_atom(module) and module not in [:integer, :string] do + when is_atom(module) and module not in [:integer, :string, :boolean, :float] do module.parse(orig_value) end @@ -103,15 +111,15 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Field do end def encode(:any, field_value) do - field_value + {:ok, field_value} end def encode({:literal, value}, _) do - value + {:ok, value} end def encode({:optional, _}, nil) do - :"$__drop__" + {:ok, :"$__drop__"} end def encode({:optional, field_type}, field_value) do @@ -128,44 +136,99 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Field do end def encode({:list, list_type}, field_value) when is_list(field_value) do - Enum.map(field_value, &encode(list_type, &1)) + encoded = + Enum.reduce_while(field_value, [], fn element, acc -> + case encode(list_type, element) do + {:ok, encoded} -> {:cont, [encoded | acc]} + error -> {:halt, error} + end + end) + + case encoded do + encoded_list when is_list(encoded_list) -> + {:ok, Enum.reverse(encoded_list)} + + error -> + error + end end - def encode(:integer, field_value) do - field_value + def encode(:integer, field_value) when is_integer(field_value) do + {:ok, field_value} + end + + def encode(:integer, string_value) when is_binary(string_value) do + case Integer.parse(string_value) do + {int_value, ""} -> {:ok, int_value} + _ -> {:error, {:invalid_integer, string_value}} + end + end + + def encode(:float, float_value) when is_float(float_value) do + {:ok, float_value} end def encode(:string, field_value) when is_binary(field_value) do - field_value + {:ok, field_value} end def encode(:boolean, field_value) when is_boolean(field_value) do - field_value + {:ok, field_value} end def encode({:map, value_type, _}, field_value) when is_map(field_value) do - Map.new(field_value, fn {k, v} -> {k, encode(value_type, v)} end) + map_fields = + Enum.reduce_while(field_value, [], fn {key, value}, acc -> + case encode(value_type, value) do + {:ok, encoded_value} -> {:cont, [{key, encoded_value} | acc]} + error -> {:halt, error} + end + end) + + case map_fields do + fields when is_list(fields) -> {:ok, Map.new(fields)} + error -> error + end end def encode({:params, param_defs}, field_value) when is_map(field_value) do - Map.new(param_defs, fn {param_name, param_type} -> - {param_name, encode(param_type, Map.get(field_value, param_name))} - end) + param_fields = + Enum.reduce_while(param_defs, [], fn {param_name, param_type}, acc -> + unencoded = Map.get(field_value, param_name) + + case encode(param_type, unencoded) do + {:ok, encoded_value} -> {:cont, [{param_name, encoded_value} | acc]} + error -> {:halt, error} + end + end) + + case param_fields do + fields when is_list(fields) -> {:ok, Map.new(fields)} + error -> error + end end def encode({:constant, constant_module}, field_value) do - constant_module.encode(field_value) + {:ok, constant_module.encode(field_value)} + end + + def encode({:type_alias, alias_module}, field_value) do + encode(alias_module.definition(), field_value) end def encode(module, field_value) when is_atom(module) do if function_exported?(module, :encode, 1) do module.encode(field_value) else - field_value + {:ok, field_value} end end def encode(_, nil) do nil end + + def encode(type, value) do + {:error, {:invalid_type, type, value}} + end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/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/json.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/json.ex index 0a4ef5aac..0c08f97a8 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/macros/json.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/json.ex @@ -8,7 +8,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Json do encoded_pairs = for {field_name, field_type} <- unquote(dest_module).__meta__(:types), field_value = get_field_value(value, field_name), - encoded_value = Field.encode(field_type, field_value), + {:ok, encoded_value} = Field.encode(field_type, field_value), encoded_value != :"$__drop__" do {field_name, encoded_value} end @@ -17,7 +17,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Json do |> Enum.flat_map(fn # flatten the spread into the current map {:.., value} when is_map(value) -> Enum.to_list(value) - {k, v} -> [{k, v}] + {k, v} -> [{camelize(k), v}] end) |> JasonVendored.Encode.keyword(opts) end @@ -29,6 +29,17 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Json do defp get_field_value(struct, field_name) do Map.get(struct, field_name) end + + def camelize(field_name) do + field_name + |> to_string() + |> Macro.camelize() + |> downcase_first() + end + + defp downcase_first(<>) do + String.downcase(c) <> rest + end end end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/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/macros/struct.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/struct.ex index cc770dba7..2fece5328 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/macros/struct.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/macros/struct.ex @@ -1,6 +1,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Struct do def build(opts) do keys = Keyword.keys(opts) + required_keys = required_keys(opts) keys = if :.. in keys do @@ -20,13 +21,28 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Macros.Struct do end quote location: :keep do + @enforce_keys unquote(required_keys) defstruct unquote(keys) def new(opts \\ []) do - struct(__MODULE__, opts) + struct!(__MODULE__, opts) end defoverridable new: 0, new: 1 end end + + defp required_keys(opts) do + Enum.filter(opts, fn + # ignore the splat, it's always optional + {:.., _} -> false + # an optional signifier tuple + {_, {:optional, _}} -> false + # ast for an optional signifier tuple + {_, {:optional, _, _}} -> false + # everything else is required + _ -> true + end) + |> Keyword.keys() + end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex index eb82449a4..962e533ab 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/notification.ex @@ -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 = [ @@ -13,16 +13,23 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Notification do param_names = Keyword.keys(types) lsp_types = Keyword.merge(jsonrpc_types, types) elixir_types = Message.generate_elixir_types(__CALLER__.module, lsp_types) + lsp_module_name = Module.concat(__CALLER__.module, LSP) quote location: :keep do defmodule LSP do - unquote(Message.build({:notification, :lsp}, method, lsp_types, param_names)) + unquote(Message.build({:notification, :lsp}, method, access, lsp_types, param_names)) + + def new(opts \\ []) do + opts + |> Keyword.merge(method: unquote(method), jsonrpc: "2.0") + |> super() + end end alias ElixirLS.LanguageServer.Experimental.Protocol.Proto.Convert unquote( - Message.build({:notification, :elixir}, method, elixir_types, param_names, + Message.build({:notification, :elixir}, method, access, elixir_types, param_names, include_parse?: false ) ) @@ -30,12 +37,33 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Notification do unquote(build_parse(method)) def new(opts \\ []) do - %__MODULE__{lsp: LSP.new(opts), method: unquote(method)} + opts = Keyword.merge(opts, method: unquote(method), jsonrpc: "2.0") + + # use struct here because initially, the non-lsp struct doesn't have + # to be filled out. Calling to_elixir fills it out. + struct(__MODULE__, lsp: LSP.new(opts), method: unquote(method), jsonrpc: "2.0") end def to_elixir(%__MODULE__{} = request) do Convert.to_elixir(request) end + + defimpl JasonVendored.Encoder, for: unquote(__CALLER__.module) do + def encode(notification, opts) do + JasonVendored.Encoder.encode(notification.lsp, opts) + end + end + + defimpl JasonVendored.Encoder, for: unquote(lsp_module_name) do + def encode(notification, opts) do + %{ + jsonrpc: "2.0", + method: unquote(method), + params: Map.take(notification, unquote(param_names)) + } + |> JasonVendored.Encode.map(opts) + end + end end end diff --git a/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex index a1101af5a..9d7da0816 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/request.ex @@ -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,24 @@ 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)) + + def new(opts \\ []) do + opts + |> Keyword.merge(method: unquote(method), jsonrpc: "2.0") + |> super() + end 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 ) ) @@ -37,13 +44,35 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Request do unquote(build_parse(method)) def new(opts \\ []) do + opts = Keyword.merge(opts, method: unquote(method), jsonrpc: "2.0") + raw = LSP.new(opts) - %__MODULE__{lsp: raw, id: raw.id, method: unquote(method)} + # use struct here because initially, the non-lsp struct doesn't have + # to be filled out. Calling to_elixir fills it out. + struct(__MODULE__, lsp: raw, id: raw.id, method: unquote(method), jsonrpc: "2.0") end def to_elixir(%__MODULE__{} = request) do 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/response.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/response.ex index 5726da650..98f5efb7d 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/response.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/response.ex @@ -15,7 +15,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.Response do jsonrpc_types = [ id: quote(do: optional(one_of([integer(), string()]))), error: quote(do: optional(LspTypes.ResponseError)), - result: response_type + result: quote(do: optional(unquote(response_type))) ] quote location: :keep do diff --git a/apps/language_server/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/proto/type_functions.ex b/apps/language_server/lib/language_server/experimental/protocol/proto/type_functions.ex index e74f2b209..290ac61ed 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/proto/type_functions.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/proto/type_functions.ex @@ -3,6 +3,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.TypeFunctions do :integer end + def float do + :float + end + def string do :string end @@ -15,6 +19,10 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Proto.TypeFunctions do :string end + def type_alias(alias_module) do + {:type_alias, alias_module} + end + def literal(what) do {:literal, what} end diff --git a/apps/language_server/lib/language_server/experimental/protocol/requests.ex b/apps/language_server/lib/language_server/experimental/protocol/requests.ex index f13f85dd3..7094ea3e2 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/requests.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/requests.ex @@ -1,14 +1,57 @@ 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 + # Client -> Server request + 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(any()), + trace: optional(string()), + workspace_folders: optional(list_of(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 CodeAction do + use Proto + + defrequest "textDocument/codeAction", :exclusive, + text_document: Types.TextDocument.Identifier, + range: Types.Range, + context: Types.CodeActionContext + end + + # Server -> Client requests + + defmodule RegisterCapability do + use Proto + + defrequest "client/registerCapability", :shared, + registrations: optional(list_of(LspTypes.Registration)) + end + use Proto, decoders: :requests end diff --git a/apps/language_server/lib/language_server/experimental/protocol/responses.ex b/apps/language_server/lib/language_server/experimental/protocol/responses.ex index f44c4bddd..beebeac88 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,23 @@ 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 + + defmodule CodeAction do + use Proto + + defresponse optional(list_of(Types.CodeAction)) + end + + @type response :: FindReferences.t() | CodeAction.t() | Formatting.t() end diff --git a/apps/language_server/lib/language_server/experimental/protocol/types.ex b/apps/language_server/lib/language_server/experimental/protocol/types.ex index afa2862d8..0d69715ea 100644 --- a/apps/language_server/lib/language_server/experimental/protocol/types.ex +++ b/apps/language_server/lib/language_server/experimental/protocol/types.ex @@ -36,12 +36,25 @@ defmodule ElixirLS.LanguageServer.Experimental.Protocol.Types do deftype uri: uri(), version: integer() end + defmodule TextDocument.OptionalVersionedIdentifier do + use Proto + + deftype uri: uri(), version: optional(integer()) + end + defmodule TextDocument.ContentChangeEvent do use Proto deftype range: optional(Range), text: string() end + defmodule TextDocument.Edit do + use Proto + + deftype text_document: TextDocument.OptionalVersionedIdentifier, + edits: list_of(TextEdit) + end + defmodule CodeDescription do use Proto @@ -79,7 +92,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 +105,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 +157,14 @@ 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 WorkspaceEdit do + use Proto + + deftype document_changes: optional(list_of(TextDocument.Edit)), + changes: optional(map_of(list_of(TextEdit))) end defmodule DidChangeConfiguration.ClientCapabilities do @@ -247,7 +272,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 +443,66 @@ 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 + + defmodule Command do + use Proto + + deftype title: string(), + command: string(), + arguments: optional(list_of(any())) + end + + defmodule CodeActionKind do + use Proto + + defenum empty: "", + quick_fix: "quickfix", + refactor: "refactor", + refactor_extract: "refactor.extract", + refactor_inline: "refactor.inline", + refactor_rewrite: "refactor.rewrite", + source: "source", + source_organize_imports: "source.organizeImports", + source_fix_all: "source.fixAll" + end + + defmodule CodeActionTriggerKind do + use Proto + + defenum invoked: 1, + automatic: 2 + end + + defmodule CodeActionContext do + use Proto + + deftype diagnostics: list_of(Diagnostic), + only: optional(list_of(CodeActionKind)), + trigger_kind: optional(CodeActionTriggerKind) + end + + defmodule CodeAction do + use Proto + + deftype title: string(), + kind: optional(CodeActionKind), + diagnostics: optional(list_of(Diagnostic)), + is_preferred: optional(boolean()), + edit: optional(WorkspaceEdit), + command: optional(Command), + data: optional(any()) + end end diff --git a/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex new file mode 100644 index 000000000..1abe91a82 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex @@ -0,0 +1,83 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore do + @moduledoc """ + A code action that prefixes unused variables with an underscore + """ + + alias ElixirLS.LanguageServer.Experimental.CodeMod + alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction, as: CodeActionResult + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Diagnostic + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.WorkspaceEdit + alias ElixirLS.LanguageServer.Experimental.SourceFile + + @spec apply(CodeAction.t()) :: [CodeActionResult.t()] + def apply(%CodeAction{} = code_action) do + source_file = code_action.source_file + diagnostics = get_in(code_action, [:context, :diagnostics]) || [] + + diagnostics + |> Enum.flat_map(fn %Diagnostic{} = diagnostic -> + with {:ok, variable_name, one_based_line} <- extract_variable_and_line(diagnostic), + {:ok, reply} <- build_code_action(source_file, one_based_line, variable_name) do + [reply] + else + _ -> + [] + end + end) + end + + defp build_code_action(%SourceFile{} = source_file, one_based_line, variable_name) do + with {:ok, line_text} <- SourceFile.fetch_text_at(source_file, one_based_line), + {:ok, line_ast} <- Ast.from(line_text), + {:ok, text_edits} <- + CodeMod.ReplaceWithUnderscore.text_edits(line_text, line_ast, variable_name) do + case text_edits do + [] -> + :error + + [_ | _] -> + text_edits = Enum.map(text_edits, &update_line(&1, one_based_line)) + + reply = + CodeActionResult.new( + title: "Rename to _#{variable_name}", + kind: :quick_fix, + edit: WorkspaceEdit.new(changes: %{source_file.uri => text_edits}) + ) + + {:ok, reply} + end + end + end + + defp update_line(%TextEdit{} = text_edit, line_number) do + text_edit + |> put_in([:range, :start, :line], line_number - 1) + |> put_in([:range, :end, :line], line_number - 1) + end + + defp extract_variable_and_line(%Diagnostic{} = diagnostic) do + with {:ok, variable_name} <- extract_variable_name(diagnostic.message), + {:ok, line} <- extract_line(diagnostic) do + {:ok, variable_name, line} + end + end + + @variable_re ~r/variable "([^"]+)" is unused/ + defp extract_variable_name(message) do + case Regex.scan(@variable_re, message) do + [[_, variable_name]] -> + {:ok, String.to_atom(variable_name)} + + _ -> + :error + end + end + + defp extract_line(%Diagnostic{} = diagnostic) do + {:ok, diagnostic.range.start.line} + end +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..1bdde12cf --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/env.ex @@ -0,0 +1,27 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.Env do + @moduledoc """ + An environment passed to provider handlers. + This represents the current state of the project, and should include additional + information that provider handles might need to complete their tasks. + """ + + alias ElixirLS.LanguageServer.Experimental.Project + alias ElixirLS.LanguageServer.Experimental.Server.Configuration + + 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/code_action.ex b/apps/language_server/lib/language_server/experimental/provider/handlers/code_action.ex new file mode 100644 index 000000000..c915eb10a --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/handlers/code_action.ex @@ -0,0 +1,16 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.Handlers.CodeAction do + alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore + alias ElixirLS.LanguageServer.Experimental.Provider.Env + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests + alias ElixirLS.LanguageServer.Experimental.Protocol.Responses + alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore + + require Logger + + def handle(%Requests.CodeAction{} = request, %Env{}) do + code_actions = ReplaceWithUnderscore.apply(request) + reply = Responses.CodeAction.new(request.id, code_actions) + + {:reply, reply} + end +end diff --git a/apps/language_server/lib/language_server/experimental/provider/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..0929a2e70 --- /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.CodeMod.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..834594b93 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/provider/queue.ex @@ -0,0 +1,229 @@ +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, + Requests.CodeAction => Handlers.CodeAction + } + + 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..b1c285c74 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.configuration) + {: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,16 +111,9 @@ 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} end end diff --git a/apps/language_server/lib/language_server/experimental/server/configuration.ex b/apps/language_server/lib/language_server/experimental/server/configuration.ex new file mode 100644 index 000000000..192700a9c --- /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 + + 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.RegisterCapability.t()} + | {:restart, Logger.level(), String.t()} + | {:error, 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()} + | {:error, 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..d4cb79ddd 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,66 @@ 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 + + @type t :: %__MODULE__{} def new do %__MODULE__{} end + def initialize(%__MODULE__{initialized?: false} = state, %Initialize{ + lsp: %Initialize.LSP{} = event + }) do + config = Configuration.new(event.root_uri, event.capabilities) + new_state = %__MODULE__{state | configuration: config, initialized?: true} + {:ok, new_state} + end + + def initialize(%__MODULE__{initialized?: true}, %Initialize{}) do + {:error, :already_initialized} + end + + def default_configuration(%__MODULE__{configuration: config} = state) do + with {:ok, config} <- Configuration.default(config) do + {:ok, %__MODULE__{state | configuration: config}} + end + end + + def apply(%__MODULE__{initialized?: false}, request) do + Logger.error("Received #{request.method} before server was initialized") + {:error, :not_initialized} + end + + def apply(%__MODULE__{} = state, %DidChangeConfiguration{} = event) do + case Configuration.on_change(state.configuration, event) do + {:ok, config} -> + {:ok, %__MODULE__{state | configuration: config}} + + {:ok, config, response} -> + WireProtocol.send(response) + {:ok, %__MODULE__{state | configuration: config}} + + error -> + error + end + end + def apply(%__MODULE__{} = state, %DidChange{lsp: event}) do uri = event.text_document.uri version = event.text_document.version diff --git a/apps/language_server/lib/language_server/experimental/source_file.ex b/apps/language_server/lib/language_server/experimental/source_file.ex index e427c5991..b45f428df 100644 --- a/apps/language_server/lib/language_server/experimental/source_file.ex +++ b/apps/language_server/lib/language_server/experimental/source_file.ex @@ -3,6 +3,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions alias ElixirLS.LanguageServer.Experimental.SourceFile.Document + alias ElixirLS.LanguageServer.Experimental.SourceFile.Line alias ElixirLS.LanguageServer.Experimental.SourceFile.Position alias ElixirLS.LanguageServer.Experimental.SourceFile.Range alias ElixirLS.LanguageServer.SourceFile @@ -22,8 +23,10 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do @type version :: pos_integer() @type change_application_error :: {:error, {:invalid_range, map()}} # public - @spec new(URI.t(), String.t(), pos_integer()) :: t + def new(uri, text, version) do + uri = Conversions.ensure_uri(uri) + %__MODULE__{ uri: uri, version: version, @@ -32,6 +35,11 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do } end + @spec size(t) :: non_neg_integer() + def size(%__MODULE__{} = source) do + Document.size(source.document) + end + @spec mark_dirty(t) :: t def mark_dirty(%__MODULE__{} = source) do %__MODULE__{source | dirty?: true} @@ -44,11 +52,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 @@ -178,7 +192,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do end defp apply_valid_edits(%__MODULE{} = source, edit_text, start_pos, end_pos) do - Enum.reduce(source.document, [], fn line() = line, acc -> + Document.reduce(source.document, [], fn line() = line, acc -> case edit_action(line, edit_text, start_pos, end_pos) do :drop -> acc @@ -221,15 +235,16 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile do end end - defp utf8_prefix(line(text: text), start_index) do - length = max(0, start_index) + defp utf8_prefix(line(text: text), start_code_unit) do + length = max(0, start_code_unit) binary_part(text, 0, length) end - defp utf8_suffix(line(text: text), start_index) do + defp utf8_suffix(line(text: text), start_code_unit) do byte_count = byte_size(text) - start_index = min(start_index, byte_count) + start_index = min(start_code_unit, byte_count) length = byte_count - start_index + binary_part(text, start_index, length) end diff --git a/apps/language_server/lib/language_server/experimental/source_file/conversions.ex b/apps/language_server/lib/language_server/experimental/source_file/conversions.ex index f0567c38d..c019b74fb 100644 --- a/apps/language_server/lib/language_server/experimental/source_file/conversions.ex +++ b/apps/language_server/lib/language_server/experimental/source_file/conversions.ex @@ -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 @@ -70,22 +81,27 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do {:ok, ElixirPosition.new(elixir_line_number, 0)} true -> - with {:ok, line} <- Document.fetch_line(document, elixir_line_number) do - elixir_character = - case line do - line(ascii?: true, text: text) -> - min(ls_character, byte_size(text)) - - line(text: text) -> - {:ok, utf16_text} = to_utf16(text) - lsp_character_to_elixir(utf16_text, ls_character) - end - + with {:ok, line} <- Document.fetch_line(document, elixir_line_number), + {:ok, elixir_character} <- extract_elixir_character(position, line) do {:ok, ElixirPosition.new(elixir_line_number, elixir_character)} end end 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 @@ -98,72 +114,42 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Conversions do end def to_lsp(%ElixirPosition{} = position, %Document{} = document) do - %ElixirPosition{character: elixir_character, line: elixir_line} = position - - with {:ok, line} <- Document.fetch_line(document, elixir_line) do - lsp_character = - case line do - line(ascii?: true, text: text) -> - min(position.character, byte_size(text)) + with {:ok, line} <- Document.fetch_line(document, position.line), + {:ok, lsp_character} <- extract_lsp_character(position, line) do + ls_pos = + LSPosition.new(character: lsp_character, line: position.line - @elixir_ls_index_base) - line(text: utf8_text) -> - {:ok, character} = elixir_character_to_lsp(utf8_text, elixir_character) - character - end - - ls_pos = LSPosition.new(character: lsp_character, line: elixir_line - @elixir_ls_index_base) {:ok, ls_pos} end end def to_lsp(%LSPosition{} = position, _) do - position + {:ok, position} end - defp lsp_character_to_elixir(utf16_line, lsp_character) do - # In LSP, the word "character" is a misnomer. What's being counted is a code unit. - # in utf16, a code unit is two bytes long, while in utf8 it is one byte long. - # This function converts from utf16 code units to utf8 code units. The code units - # can then be used to do a simple byte-level operation on elixir binaries. - # For ascii text, the code unit will mirror the number of bytes, but if there's any - # unicode characters, it will vary from the byte count. - byte_size = byte_size(utf16_line) - - # if character index is over the length of the string assume we pad it with spaces (1 byte in utf8) - utf16_line - |> binary_part(0, min(lsp_character * 2, byte_size)) - |> to_utf8() - |> byte_size() - end - - def elixir_character_to_lsp(utf8_line, elixir_character) do - case utf8_line |> binary_part(0, elixir_character) |> to_utf16() do - {:ok, utf16_line} -> - character = - utf16_line - |> byte_size() - |> div(2) + # Private - {:ok, character} + defp extract_lsp_character(%ElixirPosition{} = position, line(ascii?: true, text: text)) do + character = min(position.character, byte_size(text)) + {:ok, character} + end - error -> - error + defp extract_lsp_character(%ElixirPosition{} = position, line(text: utf8_text)) do + with {:ok, code_unit} <- CodeUnit.to_utf16(utf8_text, position.character) do + character = min(code_unit, CodeUnit.count(:utf16, utf8_text)) + {:ok, character} end end - defp to_utf16(b) do - case :unicode.characters_to_binary(b, :utf8, :utf16) do - b when is_binary(b) -> {:ok, b} - {:error, _, _} = err -> err - {:incomplete, _, _} -> {:error, :incomplete} - end + defp extract_elixir_character(%LSPosition{} = position, line(ascii?: true, text: text)) do + character = min(position.character, byte_size(text)) + {:ok, character} end - defp to_utf8(b) do - case :unicode.characters_to_binary(b, :utf16, :utf8) do - b when is_binary(b) -> b - {:error, _, _} = err -> err - {:incomplete, _, _} -> {:error, :incomplete} + defp extract_elixir_character(%LSPosition{} = position, line(text: utf8_text)) do + with {:ok, code_unit} <- CodeUnit.to_utf8(utf8_text, position.character) do + character = min(code_unit, byte_size(utf8_text)) + {:ok, character} end end end diff --git a/apps/language_server/lib/language_server/experimental/source_file/document.ex b/apps/language_server/lib/language_server/experimental/source_file/document.ex index 6060de5d2..0a3538c93 100644 --- a/apps/language_server/lib/language_server/experimental/source_file/document.ex +++ b/apps/language_server/lib/language_server/experimental/source_file/document.ex @@ -17,7 +17,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Document do end def to_iodata(%__MODULE__{} = document) do - Enum.reduce(document, [], fn line(text: text, ending: ending), acc -> + reduce(document, [], fn line(text: text, ending: ending), acc -> [acc | [text | ending]] end) end @@ -32,12 +32,31 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Document do tuple_size(document.lines) end + def fetch_line(%__MODULE__{lines: lines, starting_index: starting_index}, index) + when index - starting_index >= tuple_size(lines) do + :error + end + def fetch_line(%__MODULE__{} = document, index) when is_integer(index) do - case Enum.at(document, index - document.starting_index) do + case elem(document.lines, index - document.starting_index) do line() = line -> {:ok, line} _ -> :error end end + + def reduce(%__MODULE__{} = document, initial, reducer_fn) do + size = size(document) + + if size == 0 do + initial + else + Enum.reduce(0..(size - 1), initial, fn index, acc -> + document.lines + |> elem(index) + |> reducer_fn.(acc) + end) + end + end end defimpl Enumerable, for: ElixirLS.LanguageServer.Experimental.SourceFile.Document do diff --git a/apps/language_server/lib/language_server/experimental/source_file/line.ex b/apps/language_server/lib/language_server/experimental/source_file/line.ex index 4494fd0b1..75e313d4b 100644 --- a/apps/language_server/lib/language_server/experimental/source_file/line.ex +++ b/apps/language_server/lib/language_server/experimental/source_file/line.ex @@ -2,4 +2,6 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Line do import Record defrecord :line, text: nil, ending: nil, line_number: 0, ascii?: true + + @type t :: tuple() end diff --git a/apps/language_server/lib/language_server/experimental/source_file/store.ex b/apps/language_server/lib/language_server/experimental/source_file/store.ex index 03effa211..8cf79ea38 100644 --- a/apps/language_server/lib/language_server/experimental/source_file/store.ex +++ b/apps/language_server/lib/language_server/experimental/source_file/store.ex @@ -1,9 +1,11 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do - alias ElixirLS.LanguageServer.Experimental.SourceFile - defmodule State do + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions alias ElixirLS.LanguageServer.Experimental.SourceFile.Store - defstruct source_files: %{} + require Logger + + defstruct source_files: %{}, temp_files: %{}, temporary_open_refs: %{} @type t :: %__MODULE__{} def new do %__MODULE__{} @@ -11,9 +13,9 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do @spec fetch(t, Store.uri()) :: {:ok, SourceFile.t()} | {:error, :not_open} def fetch(%__MODULE__{} = store, uri) do - case Map.fetch(store.source_files, uri) do - :error -> {:error, :not_open} - success -> success + with :error <- Map.fetch(store.source_files, uri), + :error <- Map.fetch(store.temp_files, uri) do + {:error, :not_open} end end @@ -30,6 +32,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do end end + @spec open(t, Store.uri(), String.t(), pos_integer()) :: {:ok, t} | {:error, :already_open} def open(%__MODULE__{} = store, uri, text, version) do case Map.fetch(store.source_files, uri) do {:ok, _} -> @@ -42,6 +45,10 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do end end + def open?(%__MODULE__{} = store, uri) do + Map.has_key?(store.source_files, uri) or Map.has_key?(store.temp_files, uri) + end + def close(%__MODULE__{} = store, uri) do case Map.pop(store.source_files, uri) do {nil, _store} -> @@ -74,10 +81,78 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do end end + def open_temporarily(%__MODULE__{} = store, path_or_uri, timeout) do + uri = Conversions.ensure_uri(path_or_uri) + path = Conversions.ensure_path(path_or_uri) + + with {:ok, contents} <- File.read(path) do + source_file = SourceFile.new(uri, contents, 0) + ref = schedule_unload(uri, timeout) + + new_refs = + store + |> maybe_cancel_old_ref(uri) + |> Map.put(uri, ref) + + temp_files = Map.put(store.temp_files, uri, source_file) + + new_store = %__MODULE__{store | temp_files: temp_files, temporary_open_refs: new_refs} + + {:ok, source_file, new_store} + end + end + + def extend_timeout(%__MODULE__{} = store, uri, timeout) do + case store.temporary_open_refs do + %{^uri => ref} -> + Process.cancel_timer(ref) + new_ref = schedule_unload(uri, timeout) + new_open_refs = Map.put(store.temporary_open_refs, uri, new_ref) + %__MODULE__{store | temporary_open_refs: new_open_refs} + + _ -> + store + end + end + + def unload(%__MODULE__{} = store, uri) do + new_refs = Map.delete(store.temporary_open_refs, uri) + temp_files = Map.delete(store.temp_files, uri) + + %__MODULE__{ + store + | temp_files: temp_files, + temporary_open_refs: new_refs + } + end + + defp maybe_cancel_old_ref(%__MODULE__{} = store, uri) do + {_, new_refs} = + Map.get_and_update(store.temporary_open_refs, uri, fn + nil -> + :pop + + old_ref when is_reference(old_ref) -> + Process.cancel_timer(old_ref) + :pop + end) + + new_refs + end + + defp schedule_unload(uri, timeout) do + Process.send_after(self(), {:unload, uri}, timeout) + end + defp normalize_error(:error), do: {:error, :not_open} defp normalize_error(e), do: e end + alias ElixirLS.LanguageServer.Experimental.ProcessCache + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Experimental.SourceFile.Conversions + import ElixirLS.LanguageServer.Experimental.Log + @type t :: %State{} @type uri :: String.t() @@ -95,11 +170,27 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do GenServer.call(__MODULE__, {:save, uri}) end + @spec open?(uri()) :: boolean() + def open?(uri) do + GenServer.call(__MODULE__, {:open?, uri}) + end + @spec open(uri(), String.t(), pos_integer()) :: :ok | {:error, :already_open} def open(uri, text, version) do GenServer.call(__MODULE__, {:open, uri, text, version}) end + def open_temporary(uri, timeout \\ 5000) do + path = uri |> Conversions.ensure_path() |> Path.basename() + file_name = Path.basename(path) + + ProcessCache.trans(uri, 50, fn -> + log_and_time "open temporarily: #{file_name}" do + GenServer.call(__MODULE__, {:open_temporarily, uri, timeout}) + end + end) + end + @spec close(uri()) :: :ok | {:error, :not_open} def close(uri) do GenServer.call(__MODULE__, {:close, uri}) @@ -153,6 +244,27 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do {:reply, reply, new_state} end + def handle_call({:open_temporarily, uri, timeout_ms}, _, %State{} = state) do + {reply, new_state} = + with {:error, :not_open} <- State.fetch(state, uri), + {:ok, source_file, new_state} <- State.open_temporarily(state, uri, timeout_ms) do + {{:ok, source_file}, new_state} + else + {:ok, source_file} -> + new_state = State.extend_timeout(state, uri, timeout_ms) + {{:ok, source_file}, new_state} + + error -> + {error, state} + end + + {:reply, reply, new_state} + end + + def handle_call({:open?, uri}, _from, %State{} = state) do + {:reply, State.open?(state, uri), state} + end + def handle_call({:close, uri}, _from, %State{} = state) do {reply, new_state} = case State.close(state, uri) do @@ -182,4 +294,8 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.Store do {:reply, reply, new_state} end + + def handle_info({:unload, uri}, %State{} = state) do + {:noreply, State.unload(state, uri)} + end end diff --git a/apps/language_server/lib/language_server/experimental/supervisor.ex b/apps/language_server/lib/language_server/experimental/supervisor.ex new file mode 100644 index 000000000..0d35acbd8 --- /dev/null +++ b/apps/language_server/lib/language_server/experimental/supervisor.ex @@ -0,0 +1,21 @@ +defmodule ElixirLS.LanguageServer.Experimental.Supervisor do + alias ElixirLS.LanguageServer.Experimental + alias ElixirLS.LanguageServer.Experimental.Provider + use Supervisor + + def start_link(init_arg) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl Supervisor + def init(_) do + children = [ + Experimental.SourceFile.Store, + Experimental.Server, + Provider.Queue.Supervisor.child_spec(), + Provider.Queue.child_spec() + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/apps/language_server/lib/language_server/packet_router.ex b/apps/language_server/lib/language_server/packet_router.ex index bbb83f112..9fb3c4fa0 100644 --- a/apps/language_server/lib/language_server/packet_router.ex +++ b/apps/language_server/lib/language_server/packet_router.ex @@ -1,6 +1,6 @@ defmodule ElixirLS.LanguageServer.PacketRouter do defmodule State do - defstruct monitor_references: %{} + defstruct monitor_references: %{}, names_to_pids: %{} def new(names_or_pids) when is_list(names_or_pids) do Enum.reduce(names_or_pids, %__MODULE__{}, &add(&2, &1)) diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index ef67200f4..9636062f5 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -17,6 +17,7 @@ defmodule ElixirLS.LanguageServer.Server do use GenServer require Logger + alias ElixirLS.LanguageServer.Server.Decider alias ElixirLS.LanguageServer.{SourceFile, Build, Protocol, JsonRpc, Dialyzer, Diagnostics} alias ElixirLS.LanguageServer.Providers.{ @@ -168,29 +169,57 @@ defmodule ElixirLS.LanguageServer.Server do end @impl GenServer - def handle_cast({:receive_packet, request(id, _, _) = packet}, state = %__MODULE__{}) do - {:noreply, handle_request_packet(id, packet, state)} + def handle_cast({:receive_packet, request(id, method, _) = packet}, state = %__MODULE__{}) do + new_state = + if Decider.handles?(:standard, method) do + handle_request_packet(id, packet, state) + else + state + end + + {:noreply, new_state} end @impl GenServer def handle_cast({:receive_packet, request(id, method)}, state = %__MODULE__{}) do - {:noreply, handle_request_packet(id, request(id, method, nil), state)} + new_state = + if Decider.handles?(:standard, method) do + handle_request_packet(id, request(id, method, nil), state) + else + state + end + + {:noreply, new_state} end @impl GenServer def handle_cast( - {:receive_packet, notification(_) = packet}, + {:receive_packet, notification(method) = packet}, state = %__MODULE__{received_shutdown?: false, server_instance_id: server_instance_id} ) when is_initialized(server_instance_id) do - {:noreply, handle_notification(packet, state)} + new_state = + if Decider.handles?(:standard, method) do + handle_notification(packet, state) + else + state + end + + {:noreply, new_state} end @impl GenServer def handle_cast({:receive_packet, notification(_) = packet}, state = %__MODULE__{}) do case packet do notification("exit") -> - {:noreply, handle_notification(packet, state)} + new_state = + if Decider.handles?(:standard, "exit") do + handle_notification(packet, state) + else + state + end + + {:noreply, new_state} _ -> {:noreply, state} 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 7bcd48168..51370b2dd 100644 --- a/apps/language_server/mix.exs +++ b/apps/language_server/mix.exs @@ -29,11 +29,12 @@ defmodule ElixirLS.LanguageServer.Mixfile do {:elixir_ls_utils, in_umbrella: true}, {:elixir_sense, github: "elixir-lsp/elixir_sense"}, {:erl2ex, github: "dazuma/erl2ex"}, + {:sourceror, "0.11.2"}, {:dialyxir_vendored, github: "elixir-lsp/dialyxir", branch: "vendored", runtime: false}, {:jason_vendored, github: "elixir-lsp/jason", branch: "vendored"}, {:stream_data, "~> 0.5", only: [:dev, :test], runtime: false}, {: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_mod/diff_test.exs b/apps/language_server/test/experimental/code_mod/diff_test.exs new file mode 100644 index 000000000..7869f73d6 --- /dev/null +++ b/apps/language_server/test/experimental/code_mod/diff_test.exs @@ -0,0 +1,239 @@ +defmodule ElixirLS.LanguageServer.Experimental.Format.DiffTest do + alias ElixirLS.LanguageServer.Experimental.CodeMod.Diff + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Position + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextEdit + + import Diff + + use ElixirLS.Test.CodeMod.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 + + def apply_code_mod(source, _, opts) do + result = Keyword.get(opts, :result) + {:ok, Diff.diff(source, result)} + end + + def assert_edited(initial, final) do + assert {:ok, edited} = modify(initial, result: final, convert_to_ast: false) + assert edited == final + end + + describe "single line ascii diffs" do + test "a deletion at the start" do + orig = " hello" + final = "hello" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 0, 0, 2, "") + assert_edited(orig, final) + end + + test "appending in the middle" do + orig = "hello" + final = "heyello" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 2, 0, 2, "ye") + assert_edited(orig, final) + end + + test "deleting in the middle" do + orig = "hello" + final = "heo" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 2, 0, 4, "") + assert_edited(orig, final) + end + + test "inserting after a delete" do + orig = "hello" + final = "helvetica went" + + # this is collapsed into a single edit of an + # insert that spans the delete and the insert + assert [edit] = diff(orig, final) + assert edit == edit(0, 3, 0, 5, "vetica went") + assert_edited(orig, final) + end + + test "edits are ordered back to front on a line" do + orig = "hello there" + final = "hellothe" + + assert [e1, e2] = diff(orig, final) + assert e1 == edit(0, 9, 0, 11, "") + assert e2 == edit(0, 5, 0, 6, "") + end + end + + describe "applied edits" do + test "multiple edits on the same line don't conflict" do + orig = "foo( a, b)" + expected = "foo(a, b)" + + assert_edited(orig, expected) + end + end + + describe "multi line ascii diffs" do + test "multi-line deletion at the start" do + orig = + """ + none + two + hello + """ + |> String.trim() + + final = "hello" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 0, 2, 0, "") + assert_edited(orig, final) + end + + test "multi-line appending in the middle" do + orig = "hello" + final = "he\n\n ye\n\nllo" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 2, 0, 2, "\n\n ye\n\n") + assert_edited(orig, final) + end + + test "deleting multiple lines in the middle" do + orig = + """ + hello + there + people + goodbye + """ + |> String.trim() + + final = "hellogoodbye" + + assert [edit] = diff(orig, final) + assert edit == edit(0, 5, 3, 0, "") + assert_edited(orig, final) + end + + test "deleting multiple lines" do + orig = ~q[ + foo(a, + b, + c, + d) + ] + + final = ~q[ + foo(a, b, c, d) + ]t + + assert_edited(orig, final) + end + + test "deletions keep indentation" do + orig = + """ + hello + there + + + people + """ + |> String.trim() + + final = + """ + hello + there + people + """ + |> String.trim() + + assert [edit] = diff(orig, final) + assert edit == edit(2, 0, 4, 0, "") + assert_edited(orig, final) + end + end + + describe "single line emoji" do + test "deleting after" do + orig = ~S[{"🎸", "after"}] + final = ~S[{"🎸", "after"}] + + assert [edit] = diff(orig, final) + assert edit == edit(0, 7, 0, 9, "") + assert_edited(orig, final) + end + + test "inserting in the middle" do + orig = ~S[🎸🎸] + final = ~S[🎸🎺🎸] + + assert [edit] = diff(orig, final) + assert edit == edit(0, 2, 0, 2, "🎺") + assert_edited(orig, final) + end + + test "deleting in the middle" do + orig = ~S[🎸🎺🎺🎸] + final = ~S[🎸🎸] + + assert [edit] = diff(orig, final) + assert edit == edit(0, 2, 0, 6, "") + assert_edited(orig, final) + end + + test "multiple deletes on the same line" do + orig = ~S[🎸a 🎺b 🎺c 🎸] + final = ~S[🎸ab🎸] + + assert_edited(orig, final) + end + end + + describe("multi line emoji") do + test "deleting on the first line" do + orig = ~q[ + 🎸a 🎺b 🎺c 🎸 + hello + ]t + + final = ~q[ + 🎸a b c 🎸 + hello + ]t + + assert_edited(orig, final) + end + + test "deleting on subsequent lines" do + orig = ~q[ + 🎸a 🎺b 🎺c 🎸 + hello + 🎸a 🎺b 🎺c 🎸 + ]t + final = ~q[ + 🎸a 🎺b 🎺c 🎸 + ello + 🎸a 🎺b 🎺c 🎸 + ]t + + assert_edited(orig, final) + end + end +end diff --git a/apps/language_server/test/experimental/code_mod/format_test.exs b/apps/language_server/test/experimental/code_mod/format_test.exs new file mode 100644 index 000000000..3792a1824 --- /dev/null +++ b/apps/language_server/test/experimental/code_mod/format_test.exs @@ -0,0 +1,88 @@ +defmodule ElixirLS.Experimental.FormatTest do + alias ElixirLS.LanguageServer.Experimental.CodeMod.Format + alias ElixirLS.LanguageServer.Experimental.SourceFile + + use ElixirLS.Test.CodeMod.Case + + def apply_code_mod(text, _ast, opts) do + file_path = Keyword.get_lazy(opts, :file_path, &File.cwd!/0) + + text + |> source_file() + |> Format.text_edits(file_path) + end + + def source_file(text) do + SourceFile.new("file://#{__ENV__.file}", text, 1) + end + + def unformatted do + ~q[ + defmodule Unformatted do + def something( a, b ) do + end + end + ]t + end + + def formatted do + ~q[ + defmodule Unformatted do + def something(a, b) do + end + end + ]t + end + + describe "format/2" do + test "it should be able to format a file in the project" do + {:ok, result} = modify(unformatted()) + + assert result == formatted() + end + + test "it should be able to format a file when the project isn't specified" do + assert {:ok, result} = modify(unformatted(), file_path: nil) + assert result == formatted() + end + + test "it should provide an error for a syntax error" do + assert {:error, %SyntaxError{}} = ~q[ + def foo(a, ) do + true + end + ] |> modify() + end + + test "it should provide an error for a missing token" do + assert {:error, %TokenMissingError{}} = ~q[ + defmodule TokenMissing do + :bad + ] |> modify() + end + + test "it correctly handles unicode" do + assert {:ok, result} = ~q[ + {"🎸", "o"} + ] |> modify() + + assert ~q[ + {"🎸", "o"} + ]t == result + end + + test "it handles extra lines" do + assert {:ok, result} = ~q[ + defmodule Unformatted do + def something( a , b) do + + + + end + end + ] |> modify() + + assert result == formatted() + end + end +end diff --git a/apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs b/apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs new file mode 100644 index 000000000..3730f93d0 --- /dev/null +++ b/apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs @@ -0,0 +1,214 @@ +defmodule ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscoreTest do + alias ElixirLS.LanguageServer.Experimental.CodeMod.ReplaceWithUnderscore + + use ElixirLS.Test.CodeMod.Case + + def apply_code_mod(original_text, ast, options) do + variable = Keyword.get(options, :variable, :unused) + ReplaceWithUnderscore.text_edits(original_text, ast, variable) + end + + describe "fixes in parameters" do + test "applied to an unadorned param" do + {:ok, result} = + ~q[ + def my_func(unused) do + ] + |> modify() + + assert result == "def my_func(_unused) do" + end + + test "applied to a pattern match in params" do + {:ok, result} = + ~q[ + def my_func(%SourceFile{} = unused) do + ] + |> modify() + + assert result == "def my_func(%SourceFile{} = _unused) do" + end + + test "applied to a pattern match preceding a struct in params" do + {:ok, result} = + ~q[ + def my_func(unused = %SourceFile{}) do + ] + |> modify() + + assert result == "def my_func(_unused = %SourceFile{}) do" + end + + test "applied prior to a map" do + {:ok, result} = + ~q[ + def my_func(unused = %{}) do + ] + |> modify() + + assert result == "def my_func(_unused = %{}) do" + end + + test "applied after a map %{} = unused" do + {:ok, result} = + ~q[ + def my_func(%{} = unused) do + ] + |> modify() + + assert result == "def my_func(%{} = _unused) do" + end + + test "applied to a map key %{foo: unused}" do + {:ok, result} = + ~q[ + def my_func(%{foo: unused}) do + ] + |> modify() + + assert result == "def my_func(%{foo: _unused}) do" + end + + test "applied to a list element params = [unused, a, b | rest]" do + {:ok, result} = + ~q{ + def my_func([unused, a, b | rest]) do + } + |> modify() + + assert result == "def my_func([_unused, a, b | rest]) do" + end + + test "applied to the tail of a list params = [a, b, | unused]" do + {:ok, result} = + ~q{ + def my_func([a, b | unused]) do + } + |> modify() + + assert result == "def my_func([a, b | _unused]) do" + end + end + + describe "fixes in variables" do + test "applied to a variable match " do + {:ok, result} = + ~q[ + x = 3 + ] + |> modify(variable: "x") + + assert result == "_x = 3" + end + + test "applied to a variable match, preserves comments" do + {:ok, result} = + ~q[ + unused = bar # TODO: Fix this + ] + |> modify() + + assert result == "_unused = bar # TODO: Fix this" + end + + test "preserves spacing" do + {:ok, result} = + " x = 3" + |> modify(variable: "x", trim: false) + + assert result == " _x = 3" + end + + test "applied to a variable with a pattern matched struct" do + {:ok, result} = + ~q[ + unused = %Struct{} + ] + |> modify() + + assert result == "_unused = %Struct{}" + end + + test "applied to a variable with a pattern matched struct preserves trailing comments" do + {:ok, result} = + ~q[ + unused = %Struct{} # TODO: fix + ] + |> modify() + + assert result == "_unused = %Struct{} # TODO: fix" + end + + test "applied to struct param matches" do + {:ok, result} = + ~q[ + %Struct{field: unused, other_field: used} + ] + |> modify() + + assert result == "%Struct{field: _unused, other_field: used}" + end + + test "applied to a struct module match %module{}" do + {:ok, result} = + ~q[ + %unused{field: first, other_field: used} + ] + |> modify() + + assert result == "%_unused{field: first, other_field: used}" + end + + test "applied to a tuple value" do + {:ok, result} = + ~q[ + {a, b, unused, c} = whatever + ] + |> modify() + + assert result == "{a, b, _unused, c} = whatever" + end + + test "applied to a list element" do + {:ok, result} = + ~q{ + [a, b, unused, c] = whatever + } + |> modify() + + assert result == "[a, b, _unused, c] = whatever" + end + + test "applied to map value" do + {:ok, result} = + ~q[ + %{foo: a, bar: unused} = whatever + ] + |> modify() + + assert result == "%{foo: a, bar: _unused} = whatever" + end + end + + describe "fixes in structures" do + test "applied to a match of a comprehension" do + {:ok, result} = + ~q[ + for {unused, something_else} <- my_enum, do: something_else + ] + |> modify() + + assert result == "for {_unused, something_else} <- my_enum, do: something_else" + end + + test "applied to a match in a with block" do + {:ok, result} = + ~q[ + with {unused, something_else} <- my_enum, do: something_else + ] + |> modify() + + assert result == "with {_unused, something_else} <- my_enum, do: something_else" + end + end +end diff --git a/apps/language_server/test/experimental/code_unit_test.exs b/apps/language_server/test/experimental/code_unit_test.exs new file mode 100644 index 000000000..06b602065 --- /dev/null +++ b/apps/language_server/test/experimental/code_unit_test.exs @@ -0,0 +1,210 @@ +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 + # guitar is 2 code units in utf16 but 4 in utf8 + line = "b🎸abc" + assert 0 == utf16_offset(line, 0) + assert 1 == utf16_offset(line, 1) + 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", 2) + end + + test "with a multi-byte character" do + line = "🏳️‍🌈" + + code_unit_count = count_utf8_code_units(line) + + assert to_utf8(line, 0) == {:ok, 0} + assert to_utf8(line, 1) == {:error, :misaligned} + assert to_utf8(line, 2) == {:ok, 4} + assert to_utf8(line, 3) == {:ok, 7} + assert to_utf8(line, 4) == {:ok, 10} + assert to_utf8(line, 5) == {:error, :misaligned} + assert to_utf8(line, 6) == {:ok, code_unit_count} + end + + test "after a unicode character" do + 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) == {:ok, 6} + assert to_utf8(line, 7) == {:error, :misaligned} + # after the guitar character + assert to_utf8(line, 8) == {:ok, 10} + assert to_utf8(line, 9) == {:ok, 11} + 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", 2) + end + + test "with a multi-byte character" do + line = "🏳️‍🌈" + + code_unit_count = count_utf16_code_units(line) + utf8_code_unit_count = count_utf8_code_units(line) + + assert to_utf16(line, 0) == {:ok, 0} + assert to_utf16(line, 1) == {:error, :misaligned} + assert to_utf16(line, 2) == {:error, :misaligned} + assert to_utf16(line, 3) == {:error, :misaligned} + assert to_utf16(line, 4) == {:ok, 2} + assert to_utf16(line, utf8_code_unit_count - 1) == {:error, :misaligned} + assert to_utf16(line, utf8_code_unit_count) == {:ok, code_unit_count} + end + + test "after a multi-byte character" do + line = " {\"🎸\", \"ok\"}" + + utf16_code_unit_count = count_utf16_code_units(line) + utf8_code_unit_count = count_utf8_code_units(line) + + # 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) == {:ok, 6} + assert to_utf16(line, 7) == {:error, :misaligned} + assert to_utf16(line, 8) == {:error, :misaligned} + assert to_utf16(line, 9) == {:error, :misaligned} + + for index <- 10..19 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) + assert utf16_unit == utf16_unit_count + + assert {:ok, utf8_unit} = to_utf8(s, utf16_unit) + assert utf8_unit == utf8_code_unit_count + 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) + assert utf8_code_unit == utf8_code_unit_count + + assert {:ok, utf16_unit} = to_utf16(s, utf8_code_unit) + assert utf16_unit == utf16_code_unit_count + 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/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..6b6c31cd9 --- /dev/null +++ b/apps/language_server/test/experimental/project_test.exs @@ -0,0 +1,326 @@ +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) + on_exit(fn -> restore(System) end) + :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..4b2e5bf76 100644 --- a/apps/language_server/test/experimental/protocol/proto_test.exs +++ b/apps/language_server/test/experimental/protocol/proto_test.exs @@ -50,6 +50,23 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do end end + describe "float fields" do + defmodule FloatField do + use Proto + deftype float_field: float() + end + + test "can parse a float field" do + assert {:ok, val} = FloatField.parse(%{"floatField" => 494.02}) + assert val.float_field == 494.02 + end + + test "rejects nil float fields" do + assert {:error, {:invalid_value, :float_field, "string"}} = + FloatField.parse(%{"floatField" => "string"}) + end + end + describe "list fields" do defmodule ListField do use Proto @@ -99,6 +116,42 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do end end + describe "type aliases" do + defmodule TypeAlias do + use Proto + defalias one_of([string(), list_of(string())]) + end + + defmodule UsesAlias do + use Proto + + deftype alias: type_alias(TypeAlias), name: string() + end + + test "parses a single item correctly" do + assert {:ok, uses} = UsesAlias.parse(%{"name" => "uses", "alias" => "foo"}) + assert uses.name == "uses" + assert uses.alias == "foo" + end + + test "parses a list correctly" do + assert {:ok, uses} = UsesAlias.parse(%{"name" => "uses", "alias" => ["foo", "bar"]}) + assert uses.name == "uses" + assert uses.alias == ~w(foo bar) + end + + test "encodes correctly" do + assert {:ok, encoded} = encode_and_decode(UsesAlias.new(alias: "hi", name: "easy")) + assert encoded["alias"] == "hi" + assert encoded["name"] == "easy" + end + + test "parse fails if the type isn't correct" do + assert {:error, {:incorrect_type, _, %{}}} = + UsesAlias.parse(%{"name" => "ua", "alias" => %{}}) + end + end + describe "optional fields" do defmodule OptionalString do use Proto @@ -227,6 +280,26 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do end end + describe "constructors" do + defmodule RequiredFields do + use Proto + + deftype name: string(), value: optional(string()), age: integer() + end + + test "required fields are required" do + assert_raise ArgumentError, fn -> + RequiredFields.new() + end + + assert_raise ArgumentError, fn -> + RequiredFields.new(name: "hi", value: "good") + end + + assert RequiredFields.new(name: "hi", value: "good", age: 29) + end + end + def with_source_file_store(_) do source_file = """ defmodule MyTest do @@ -248,9 +321,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 +367,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 +382,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 +407,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 +440,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 +494,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 +517,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 @@ -521,7 +598,8 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do i: integer(), lit: literal("foo"), enum: Mood, - c: optional(Child) + c: optional(Child), + snake_case_name: string() end def fixture(:encoding, include_child \\ false) do @@ -531,7 +609,8 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do "l" => ~w(these are strings), "i" => 42, "enum" => 1, - "lit" => "foo" + "lit" => "foo", + "snakeCaseName" => "foo" } if include_child do @@ -553,6 +632,14 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do assert {:ok, decoded} = encode_and_decode(proto) assert decoded == expected end + + test "it camelizes encoded field names" do + expected = fixture(:encoding) + assert {:ok, proto} = EncodingTest.parse(expected) + assert proto.snake_case_name == "foo" + assert {:ok, decoded} = encode_and_decode(proto) + assert decoded["snakeCaseName"] == "foo" + end end describe "spread" do @@ -581,7 +668,7 @@ defmodule ElixirLS.LanguageServer.Experimental.ProtoTest do describe "access behavior" do defmodule Recursive do use Proto - deftype name: string(), age: integer(), child: __MODULE__ + deftype name: string(), age: integer(), child: optional(__MODULE__) end def family do diff --git a/apps/language_server/test/experimental/provider/code_action/#warning_parser.ex# b/apps/language_server/test/experimental/provider/code_action/#warning_parser.ex# new file mode 100644 index 000000000..1081b95ec --- /dev/null +++ b/apps/language_server/test/experimental/provider/code_action/#warning_parser.ex# @@ -0,0 +1,59 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.WarningParser do + @moduledoc """ + A parser for elixir warning messages + """ + + @type error_message :: String.t() + + def parse(warning_or_error) do + with {:ok, error_type} <- extract_type(warning_or_error), + {:ok, message} <- extract_message(warning_or_error), + {:ok, path, line} <- extract_path_and_line(warning_or_error), + {:ok, mfa} <- extract_mfa(warning_or_error) do + {:ok, error_type, message, path, line, mfa} + end + end + + defp extract_type(warning_or_error) do + + end + + @path_and_line_re ~r/\s+([^\:]+):(\d+)/ + def extract_path_and_line(message) do + with {:ok, line} <- fetch_line(message, 1), + [[_, path, line_number_string]] <- Regex.scan(@path_and_line_re, line), + {line_number, ""} <- Integer.parse(line_number_string) do + {:ok, path, line_number} + else + _ -> + :error + end + end + + @mfa_re ~r/:\s+([^\/]+)\/(\d+)/ + defp extract_mfa(message) do + with {:ok, line} <- fetch_line(message, 1), + [[_, module_and_function, arity_string]] <- Regex.scan(@mfa_re, line), + {arity, ""} <- Integer.parse(arity_string) do + {module, function} = split_module_and_function(module_and_function) + {:ok, {module, function, arity}} + else + _ -> + :error + end + end + + defp split_module_and_function(module_and_function) do + [function | reversed_module] = + module_and_function + |> String.split(".") + |> Enum.reverse() + + module = + reversed_module + |> Enum.reduce([], fn piece, acc -> [String.to_atom(piece) | acc] end) + |> Module.concat() + + {module, String.to_atom(function)} + end +end diff --git a/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs new file mode 100644 index 000000000..baa1195f0 --- /dev/null +++ b/apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs @@ -0,0 +1,148 @@ +defmodule ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscoreTest do + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.CodeAction + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeAction, as: CodeActionReply + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.CodeActionContext + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Diagnostic + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range + alias ElixirLS.LanguageServer.Experimental.Provider.CodeAction.ReplaceWithUnderscore + alias ElixirLS.LanguageServer.Experimental.SourceFile + alias ElixirLS.LanguageServer.Fixtures.LspProtocol + alias ElixirLS.LanguageServer.SourceFile.Path, as: SourceFilePath + + import LspProtocol + import ReplaceWithUnderscore + + use ExUnit.Case + use Patch + + setup do + {:ok, _} = start_supervised(SourceFile.Store) + :ok + end + + def diagnostic_message(file_path, line, variable_name, {module_name, function_name, arity}) do + """ + warning: variable "#{variable_name}" is unused (if the variable is not meant to be used, prefix it with an underscore) + #{file_path}:#{line}: #{module_name}.#{function_name}/#{arity} + """ + end + + def code_action(file_body, file_path, line, variable_name, opts \\ []) do + trimmed_body = String.trim(file_body, "\n") + + file_uri = SourceFilePath.to_uri(file_path) + SourceFile.Store.open(file_uri, trimmed_body, 0) + + {:ok, range} = + build(Range, + start: [line: line, character: 0], + end: [line: line, character: 0] + ) + + message_file_path = Keyword.get(opts, :message_file_path, file_path) + mfa = Keyword.get(opts, :mfa, {"MyModule", "myfunc", 1}) + + message = + Keyword.get_lazy(opts, :diagnostic_message, fn -> + diagnostic_message(message_file_path, line, variable_name, mfa) + end) + + diagnostic = Diagnostic.new(range: range, message: message) + {:ok, context} = build(CodeActionContext, diagnostics: [diagnostic]) + + {:ok, action} = + build(CodeAction, + text_document: [uri: file_uri], + range: range, + context: context + ) + + {:ok, action} = Requests.to_elixir(action) + {file_uri, action} + end + + def to_map(%Range{} = range) do + range + |> JasonVendored.encode!() + |> JasonVendored.decode!() + end + + test "produces no actions if the name or variable is not found" do + assert {_, action} = code_action("other_var = 6", "/project/file.ex", 1, "not_found") + assert [] = apply(action) + end + + test "produces no actions if the line is empty" do + {_, action} = code_action("", "/project/file.ex", 1, "a") + assert [] = apply(action) + end + + test "produces no results if the diagnostic message doesn't fit the format" do + assert {_, action} = + code_action("", "/project/file.ex", 1, "not_found", + diagnostic_message: "This isn't cool" + ) + + assert [] = apply(action) + end + + test "produces no results for buggy source code" do + {_, action} = + ~S[ + 1 + 2~/3 ; 4ab( + ] + |> code_action("/project/file.ex", 0, "unused") + + assert [] = apply(action) + end + + test "handles nil context" do + assert {_, action} = code_action("other_var = 6", "/project/file.ex", 1, "not_found") + + action = put_in(action, [:context], nil) + + assert [] = apply(action) + end + + test "handles nil diagnostics" do + assert {_, action} = code_action("other_var = 6", "/project/file.ex", 1, "not_found") + + action = put_in(action, [:context, :diagnostics], nil) + + assert [] = apply(action) + end + + test "handles empty diagnostics" do + assert {_, action} = code_action("other_var = 6", "/project/file.ex", 1, "not_found") + + action = put_in(action, [:context, :diagnostics], []) + + assert [] = apply(action) + end + + test "applied to an unadorned param" do + {file_uri, code_action} = + ~S[ + def my_func(a) do + ] + |> code_action("/project/file.ex", 0, "a") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => [edit]}}}] = apply(code_action) + assert edit.new_text == "_" + end + + test "works with multiple lines" do + {file_uri, code_action} = ~S[ + defmodule MyModule do + def my_func(a) do + end + end + ] |> code_action("/project/file.ex", 1, "a") + + assert [%CodeActionReply{edit: %{changes: %{^file_uri => [edit]}}}] = apply(code_action) + assert edit.new_text == "_" + assert edit.range.start.line == 1 + assert edit.range.end.line == 1 + end +end 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..c96c64588 --- /dev/null +++ b/apps/language_server/test/experimental/server/configuration_test.exs @@ -0,0 +1,277 @@ +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) + + on_exit(fn -> + restore(System) + end) + + :ok + end + + describe "setting env vars" do + setup [:with_an_empty_config, :with_patched_system_put_env] + + test "overwrites existing env vars if it wasn't set", ctx do + vars = %{"first_var" => "first_value", "second_var" => "second_value"} + + change = DidChangeConfiguration.new(settings: %{"envVariables" => vars}) + assert {:ok, %Configuration{} = config} = Configuration.on_change(ctx.config, change) + + expected_env_vars = %{ + "first_var" => "first_value", + "second_var" => "second_value" + } + + assert config.project.env_variables == expected_env_vars + assert_called System.put_env(^expected_env_vars) + end + end + + def with_patched_mix_target(_) do + patch(Mix, :target, :ok) + :ok + end + + describe "setting the mix target" do + setup [:with_an_empty_config, :with_patched_mix_target] + + test "allows you to set the mix target if it was unset", ctx do + change = DidChangeConfiguration.new(settings: %{"mixTarget" => "local"}) + + assert {:ok, %Configuration{} = config} = Configuration.on_change(ctx.config, change) + assert config.project.mix_target == :local + assert_called Mix.target(:local) + end + end + + describe("setting the project dir") do + setup [:with_an_empty_config] + + test "becomes part of the project if the state is empty", ctx do + change = DidChangeConfiguration.new(settings: %{"projectDir" => "sub_dir/new/dir"}) + + assert {:ok, %Configuration{} = config} = Configuration.on_change(ctx.config, change) + assert Project.project_path(config.project) == "#{File.cwd!()}/sub_dir/new/dir" + end + + test "only sets the project directory if the root uri is set" do + config = Configuration.new(nil, fixture()) + change = DidChangeConfiguration.new(settings: %{"projectDir" => "sub_dir/new/dir"}) + + assert {:ok, config} = Configuration.on_change(config, change) + assert config.project.root_uri == nil + assert Project.project_path(config.project) == nil + end + end + + def with_patched_dialyzer_support(_) do + patch(Dialyzer, :check_support, :ok) + :ok + end + + describe("setting dialyzer being enabled") do + setup [:with_an_empty_config, :with_patched_dialyzer_support] + + test "it can be enabled if it is supported", ctx do + refute ctx.config.dialyzer_enabled? + change = DidChangeConfiguration.new(settings: %{"dialyzer_enabled" => true}) + + assert {:ok, config} = Configuration.on_change(ctx.config, change) + assert config.dialyzer_enabled? + end + + test "it should be on by default", ctx do + change = DidChangeConfiguration.new(settings: %{}) + + assert {:ok, config} = Configuration.on_change(ctx.config, change) + assert config.dialyzer_enabled? + end + + test "if dialyzer is not supported, it can't be turned on", ctx do + patch(Dialyzer, :check_support, {:error, "Dialyzer is broken"}) + change = DidChangeConfiguration.new(settings: %{"dialyzer_enabled" => true}) + + assert {:ok, config} = Configuration.on_change(ctx.config, change) + refute config.dialyzer_enabled? + end + end + + describe("setting watched extensions") do + setup [:with_an_empty_config] + + test "it returns the state if no extenstions are given", ctx do + config = ctx.config + change = DidChangeConfiguration.new(settings: %{"additionalWatchedExtensions" => []}) + # ensuring it didn't send back any messages for us to process + assert {:ok, _} = Configuration.on_change(config, change) + end + + test "it returns a register capability request with watchers for each extension", ctx do + config = ctx.config + + change = + DidChangeConfiguration.new( + settings: %{"additionalWatchedExtensions" => [".ex3", ".heex"]} + ) + + assert {:ok, _config, %RegisterCapability{} = watch_request} = + Configuration.on_change(config, change) + + assert [%Registration{} = registration] = watch_request.lsp.registrations + assert registration.method == "workspace/didChangeWatchedFiles" + + assert %{"watchers" => watchers} = registration.register_options + assert %{"globPattern" => "**/*.ex3"} in watchers + assert %{"globPattern" => "**/*.heex"} in watchers + end + end +end diff --git a/apps/language_server/test/experimental/server/state_test.exs b/apps/language_server/test/experimental/server/state_test.exs index bc30ad468..b06184a62 100644 --- a/apps/language_server/test/experimental/server/state_test.exs +++ b/apps/language_server/test/experimental/server/state_test.exs @@ -1,4 +1,5 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.StateTest do + alias ElixirLS.LanguageServer.Experimental.Protocol.Requests.Initialize alias ElixirLS.LanguageServer.Experimental.Protocol.Notifications alias ElixirLS.LanguageServer.Experimental.SourceFile alias ElixirLS.LanguageServer.Experimental.Server.State @@ -16,6 +17,13 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.StateTest do "file:///file.ex" end + def initialized_state do + {:ok, initialize} = build(Initialize, root_uri: "file:///my_project", capabilities: %{}) + {:ok, state} = State.initialize(State.new(), initialize) + + state + end + def with_an_open_document(_) do {:ok, did_open} = build(Notifications.DidOpen, @@ -23,7 +31,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.StateTest do text_document: [uri: uri(), version: 1, text: "hello"] ) - {:ok, state} = State.apply(State.new(), did_open) + {:ok, state} = State.apply(initialized_state(), did_open) {:ok, state: state} end @@ -51,12 +59,12 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.StateTest do test "closing a document that isn't open fails" do {:ok, did_close} = build(Notifications.DidClose, text_document: [uri: uri()]) - assert {:error, :not_open} = State.apply(State.new(), did_close) + assert {:error, :not_open} = State.apply(initialized_state(), did_close) end test "saving a document that isn't open fails" do {:ok, save} = build(Notifications.DidSave, text_document: [uri: uri()]) - assert {:error, :not_open} = State.apply(State.new(), save) + assert {:error, :not_open} = State.apply(initialized_state(), save) end test "applying a didOpen notification" do @@ -68,7 +76,7 @@ defmodule ElixirLS.LanguageServer.Experimental.Server.StateTest do text_document: [uri: uri(), version: 1, text: "hello"] ) - {:ok, _state} = State.apply(State.new(), did_open) + {:ok, _state} = State.apply(initialized_state(), did_open) assert {:ok, file} = SourceFile.Store.fetch(uri()) assert SourceFile.to_string(file) == "hello" assert file.version == 1 diff --git a/apps/language_server/test/experimental/source_file/store_test.exs b/apps/language_server/test/experimental/source_file/store_test.exs index b195bf397..73b63d509 100644 --- a/apps/language_server/test/experimental/source_file/store_test.exs +++ b/apps/language_server/test/experimental/source_file/store_test.exs @@ -4,6 +4,7 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.StoreTest do import ElixirLS.LanguageServer.Fixtures.LspProtocol use ExUnit.Case + use Patch setup do {:ok, _} = start_supervised(SourceFile.Store) @@ -95,4 +96,49 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFile.StoreTest do ) end end + + def with_a_temp_document(_) do + contents = """ + defmodule FakeDocument do + end + """ + + patch(File, :read, fn _uri -> + {:ok, contents} + end) + + {:ok, contents: contents, uri: "file:///file.ex"} + end + + describe "a temp document" do + setup [:with_a_temp_document] + + test "can be opened", ctx do + assert {:ok, doc} = SourceFile.Store.open_temporary(ctx.uri, 100) + assert SourceFile.to_string(doc) == ctx.contents + end + + test "closes after a timeout", ctx do + assert {:ok, _} = SourceFile.Store.open_temporary(ctx.uri, 100) + Process.sleep(101) + refute SourceFile.Store.open?(ctx.uri) + end + + test "the extension is extended on subsequent access", ctx do + assert {:ok, _doc} = SourceFile.Store.open_temporary(ctx.uri, 100) + Process.sleep(75) + assert {:ok, _} = SourceFile.Store.open_temporary(ctx.uri, 100) + Process.sleep(75) + assert SourceFile.Store.open?(ctx.uri) + Process.sleep(50) + refute SourceFile.Store.open?(ctx.uri) + end + + test "opens permanently when a call to open is made", ctx do + assert {:ok, _doc} = SourceFile.Store.open_temporary(ctx.uri, 100) + assert :ok = SourceFile.Store.open(ctx.uri, ctx.contents, 1) + Process.sleep(120) + assert SourceFile.Store.open?(ctx.uri) + end + end end diff --git a/apps/language_server/test/experimental/source_file_test.exs b/apps/language_server/test/experimental/source_file_test.exs index c2737cc42..58b3d6037 100644 --- a/apps/language_server/test/experimental/source_file_test.exs +++ b/apps/language_server/test/experimental/source_file_test.exs @@ -1,11 +1,14 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFileTest do - use ExUnit.Case, async: true + alias ElixirLS.LanguageServer.Experimental + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Position + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.Range + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextDocument.ContentChangeEvent + alias ElixirLS.LanguageServer.SourceFile + use ExUnit.Case use ExUnitProperties use Patch - alias ElixirLS.LanguageServer.Experimental - alias ElixirLS.LanguageServer.SourceFile import ElixirLS.LanguageServer.Experimental.SourceFile, except: [to_string: 1] def text(%Experimental.SourceFile{} = source) do @@ -571,6 +574,67 @@ defmodule ElixirLS.LanguageServer.Experimental.SourceFileTest do assert "foooo\nbaz🏳️‍🌈z\nbaz" == text(source) end + test "works with a content change event" do + orig = """ + defmodule LanguageServer.Experimental.Server.Test do + def foo do + {"🎸", "other"} + end + end + """ + + event = + ContentChangeEvent.new( + text: "", + range: + Range.new( + start: Position.new(character: 0, line: 2), + end: Position.new(character: 22, line: 2) + ) + ) + + assert {:ok, source} = run_changes(orig, [event]) + assert {:ok, ""} = fetch_text_at(source, 3) + end + + test "deleting a line with a multi-byte character" do + orig = """ + defmodule LanguageServer.Experimental.Server.Test do + def foo do + {"🎸", "other"} + end + end + """ + + assert {:ok, source} = + run_changes(orig, [ + %{"text" => "", "range" => range_create(2, 0, 2, 19)} + ]) + + {:ok, line} = fetch_text_at(source, 3) + assert line == "" + end + + test "inserting a line with unicode" do + orig = """ + defmodule MyModule do + def func do + + end + end + """ + + assert {:ok, source} = + run_changes(orig, [ + %{"text" => " {\"🎸\", \"ok\"}", "range" => range_create(2, 0, 2, 0)}, + %{"text" => "", "range" => range_create(2, 11, 2, 13)} + ]) + + {:ok, line} = fetch_text_at(source, 3) + + assert line == " {\"🎸\", \"ok\"}" + end + test "invalid update range - before the document starts -> before the document starts" do orig = "foo\nbar" invalid_range = range_create(-2, 0, -1, 3) diff --git a/apps/language_server/test/support/experimental/code_mod/code_mod_case.ex b/apps/language_server/test/support/experimental/code_mod/code_mod_case.ex new file mode 100644 index 000000000..1c0d53002 --- /dev/null +++ b/apps/language_server/test/support/experimental/code_mod/code_mod_case.ex @@ -0,0 +1,92 @@ +defmodule ElixirLS.Test.CodeMod.Case do + alias ElixirLS.LanguageServer.Experimental.Protocol.Types.TextDocument.ContentChangeEvent + alias ElixirLS.LanguageServer.Experimental.SourceFile + + use ExUnit.CaseTemplate + + using do + quote do + import unquote(__MODULE__), only: [sigil_q: 2] + + def apply_code_mod(_, _, _) do + {:error, "You must implement apply_code_mod/3"} + end + + defoverridable apply_code_mod: 3 + + def modify(original, options \\ []) do + with {:ok, ast} <- maybe_convert_to_ast(original, options), + {:ok, edits} <- apply_code_mod(original, ast, options) do + {:ok, unquote(__MODULE__).apply_edits(original, edits, options)} + end + end + + defp maybe_convert_to_ast(code, options) do + alias ElixirLS.LanguageServer.Experimental.CodeMod.Ast + + if Keyword.get(options, :convert_to_ast, true) do + Ast.from(code) + else + {:ok, nil} + end + end + end + end + + def sigil_q(text, opts \\ []) do + ["", first | rest] = text |> String.split("\n") + base_indent = indent(first) + indent_length = String.length(base_indent) + + Enum.map_join([first | rest], "\n", &strip_leading_indent(&1, indent_length)) + |> maybe_trim(opts) + end + + def apply_edits(original, text_edits, opts) do + source_file = SourceFile.new("file:///file.ex", original, 0) + + converted_edits = + Enum.map(text_edits, fn edit -> + ContentChangeEvent.new(text: edit.new_text, range: edit.range) + end) + + {:ok, edited_source_file} = SourceFile.apply_content_changes(source_file, 1, converted_edits) + edited_source = SourceFile.to_string(edited_source_file) + + if Keyword.get(opts, :trim, true) do + String.trim(edited_source) + else + edited_source + end + end + + defp maybe_trim(iodata, [?t]) do + iodata + |> IO.iodata_to_binary() + |> String.trim_trailing() + end + + defp maybe_trim(iodata, _) do + IO.iodata_to_binary(iodata) + end + + @indent_re ~r/^\s*/ + defp indent(first_line) do + case Regex.scan(@indent_re, first_line) do + [[indent]] -> indent + _ -> "" + end + end + + defp strip_leading_indent(s, 0) do + s + end + + defp strip_leading_indent(<<" ", rest::binary>>, count) when count > 0 do + strip_leading_indent(rest, count - 1) + end + + defp strip_leading_indent(s, _) do + s + end +end 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") diff --git a/mix.lock b/mix.lock index f55a44b3a..7eca1f971 100644 --- a/mix.lock +++ b/mix.lock @@ -10,6 +10,7 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, "patch": {:hex, :patch, "0.12.0", "2da8967d382bade20344a3e89d618bfba563b12d4ac93955468e830777f816b0", [:mix], [], "hexpm", "ffd0e9a7f2ad5054f37af84067ee88b1ad337308a1cb227e181e3967127b0235"}, "path_glob_vendored": {:git, "https://github.com/elixir-lsp/path_glob.git", "965350dc41def7be4a70a23904195c733a2ecc84", [branch: "vendored"]}, + "sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"}, "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, }