From 29b91a65de19b258b93640a81408d80b5f05549a Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Fri, 20 Jan 2023 14:44:21 -0800 Subject: [PATCH] Experimental project structure (#773) * Experimental project structure This commit represents a new structure for the experimental project, and a path forward. With these changes the project now has: * A build option enabling the experimental server * Per-message routing for the experimental server. If enabled, it can either share messages with the existing server or take them over. Presently, the find references and formatting providers are implemented and "exclusive", meaning that they're handled solely by the experimental server * A consistent interface for building providers. * A consistent way to convert lsp messages into data structures and back again. This conversion is handled automatically for providers. * A genserver-like interface for providers to implement * Data structures representing LSP messages that are simple to define and build. * Fast and efficient conversion between utf8 and utf16. * A separation between what a provider does and how it responds to messages. This allows the work that underpins providers to be tested independently from the language server. * Add underscore code action Created a code action that prepends an underscore to unused variable names. * Notifications can be sent from the server * Properly handled spacing * Enforced required keys for jsonrpc messages * removed unused variable * Committed to pipeline * Added tests that check to ensure comments are preserved * Code modification framework First attempt at a standard interface for code modification. Code mod modules take the original text, the ast of the original text and arguments that they specify. They return a list of code edits or an error. * Simplified diff, change name of code action functions from appy to text_edits * Fixed off-by-one error that was vexing code unit conversions. The problem was that the character positions are _before_ the reported unit, so the 0th code unit is before the start of the line and the 1st code unit is the first character. The prior code added one to character counts to smooth this out, but you can't do that, because you could end up indexing into the middle of a multibyte character. * The code action needs to fix up the line numbers Code mods deal with snippets of code that need to have their line numbers fixed up by the code actions. * Fixed type spec The AST type is very complicated, and dialyzer was telling us I got it wrong. * Made type aliases a thing While working on the automatic protocol generators, it became clear that type aliases needed to be their own thing, as they operate quite differently from the other defined things in the jsonrpc protocol. Since they're just aliases, it makes sense to keep their definitions on hand and then spit them out when other things make use of them during encode and decode. This did require going back to encoding and ensuring all the encode functions return OK tuples. * Fixed unit tests When patches are unapplied, getting the beam file returned an empty path charlist, which dialyzer assumed was a real file name due to a weak assumption, which caused unit tests to fail. This was remedied by checking for a non-empty charlist, which allows tests to succeed. Also made patch a test only dependency for .formatter.exs, as this was causing formatters to fail. * removed unused module attribute * Added sourceror to ease ast to string conversion Under 1.12, Macro.to_string proudces wonky output, making `def` calls look like function calls by adding needless parenthesis. These parenthesis throw off the diff algorithm, and caused an off-by-one error in the code mod. Sourceror has backported the newer code generation so that it's compatible all the way back to 1.10, and produces the correct output. * Added patch as a dev dependency Patch's assertions will fail in CI due to `mix format --check-formatted` running in dev. Importing patch's deps in test will fix this. * Run check formatted in test so patch assertions work * Fixed dialyzer errors * Encapsulated sourceror --- .github/workflows/ci.yml | 4 +- apps/language_server/.formatter.exs | 12 +- apps/language_server/lib/language_server.ex | 49 ++- .../lib/language_server/dialyzer/utils.ex | 3 +- .../experimental/code_mod/ast.ex | 28 ++ .../experimental/code_mod/diff.ex | 106 ++++++ .../experimental/code_mod/format.ex | 159 +++++++++ .../code_mod/replace_with_underscore.ex | 74 ++++ .../language_server/experimental/code_unit.ex | 180 ++++++++++ .../experimental/language_server.ex | 68 ++++ .../lib/language_server/experimental/log.ex | 15 + .../experimental/process_cache.ex | 82 +++++ .../language_server/experimental/project.ex | 260 ++++++++++++++ .../experimental/protocol/id.ex | 7 + .../experimental/protocol/notifications.ex | 28 +- .../experimental/protocol/proto.ex | 7 +- .../experimental/protocol/proto/alias.ex | 22 ++ .../protocol/proto/compile_metadata.ex | 9 + .../experimental/protocol/proto/convert.ex | 87 +++-- .../experimental/protocol/proto/decoders.ex | 28 ++ .../experimental/protocol/proto/enum.ex | 6 +- .../experimental/protocol/proto/field.ex | 93 ++++- .../experimental/protocol/proto/lsp_types.ex | 16 + .../protocol/proto/macros/inspect.ex | 36 ++ .../protocol/proto/macros/json.ex | 15 +- .../protocol/proto/macros/message.ex | 10 +- .../protocol/proto/macros/struct.ex | 18 +- .../protocol/proto/notification.ex | 36 +- .../experimental/protocol/proto/request.ex | 41 ++- .../experimental/protocol/proto/requests.ex | 0 .../experimental/protocol/proto/response.ex | 2 +- .../experimental/protocol/proto/type.ex | 2 + .../protocol/proto/type_functions.ex | 8 + .../experimental/protocol/requests.ex | 45 ++- .../experimental/protocol/responses.ex | 16 +- .../experimental/protocol/types.ex | 91 ++++- .../code_action/replace_with_underscore.ex | 83 +++++ .../experimental/provider/env.ex | 27 ++ .../provider/handlers/code_action.ex | 16 + .../provider/handlers/find_references.ex | 58 ++++ .../provider/handlers/formatting.ex | 22 ++ .../experimental/provider/queue.ex | 229 ++++++++++++ .../experimental/provider/supervisor.ex | 20 ++ .../language_server/experimental/server.ex | 56 ++- .../experimental/server/configuration.ex | 137 ++++++++ .../server/configuration/support.ex | 49 +++ .../experimental/server/state.ex | 50 ++- .../experimental/source_file.ex | 37 +- .../experimental/source_file/conversions.ex | 110 +++--- .../experimental/source_file/document.ex | 23 +- .../experimental/source_file/line.ex | 2 + .../experimental/source_file/store.ex | 128 ++++++- .../experimental/supervisor.ex | 21 ++ .../lib/language_server/packet_router.ex | 2 +- .../lib/language_server/server.ex | 41 ++- .../lib/language_server/server/decider.ex | 24 ++ apps/language_server/mix.exs | 3 +- .../test/experimental/code_mod/diff_test.exs | 239 +++++++++++++ .../experimental/code_mod/format_test.exs | 88 +++++ .../code_mod/replace_with_underscore_test.exs | 214 ++++++++++++ .../test/experimental/code_unit_test.exs | 210 +++++++++++ .../test/experimental/process_cache_test.exs | 58 ++++ .../test/experimental/project_test.exs | 326 ++++++++++++++++++ .../test/experimental/protocol/proto_test.exs | 119 ++++++- .../provider/code_action/#warning_parser.ex# | 59 ++++ .../replace_with_underscore_test.exs | 148 ++++++++ .../handlers/find_references_test.exs | 234 +++++++++++++ .../provider/handlers/formatting_test.exs | 3 + .../test/experimental/provider/queue_test.exs | 101 ++++++ .../server/configuration_test.exs | 277 +++++++++++++++ .../test/experimental/server/state_test.exs | 16 +- .../experimental/source_file/store_test.exs | 46 +++ .../test/experimental/source_file_test.exs | 70 +++- .../experimental/code_mod/code_mod_case.ex | 92 +++++ .../test/support/fixtures/lsp_protocol.ex | 14 +- config/config.exs | 12 + mix.lock | 1 + 77 files changed, 4910 insertions(+), 218 deletions(-) create mode 100644 apps/language_server/lib/language_server/experimental/code_mod/ast.ex create mode 100644 apps/language_server/lib/language_server/experimental/code_mod/diff.ex create mode 100644 apps/language_server/lib/language_server/experimental/code_mod/format.ex create mode 100644 apps/language_server/lib/language_server/experimental/code_mod/replace_with_underscore.ex create mode 100644 apps/language_server/lib/language_server/experimental/code_unit.ex create mode 100644 apps/language_server/lib/language_server/experimental/language_server.ex create mode 100644 apps/language_server/lib/language_server/experimental/log.ex create mode 100644 apps/language_server/lib/language_server/experimental/process_cache.ex create mode 100644 apps/language_server/lib/language_server/experimental/project.ex create mode 100644 apps/language_server/lib/language_server/experimental/protocol/id.ex create mode 100644 apps/language_server/lib/language_server/experimental/protocol/proto/alias.ex create mode 100644 apps/language_server/lib/language_server/experimental/protocol/proto/macros/inspect.ex delete mode 100644 apps/language_server/lib/language_server/experimental/protocol/proto/requests.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/code_action/replace_with_underscore.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/env.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/handlers/code_action.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/handlers/find_references.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/handlers/formatting.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/queue.ex create mode 100644 apps/language_server/lib/language_server/experimental/provider/supervisor.ex create mode 100644 apps/language_server/lib/language_server/experimental/server/configuration.ex create mode 100644 apps/language_server/lib/language_server/experimental/server/configuration/support.ex create mode 100644 apps/language_server/lib/language_server/experimental/supervisor.ex create mode 100644 apps/language_server/lib/language_server/server/decider.ex create mode 100644 apps/language_server/test/experimental/code_mod/diff_test.exs create mode 100644 apps/language_server/test/experimental/code_mod/format_test.exs create mode 100644 apps/language_server/test/experimental/code_mod/replace_with_underscore_test.exs create mode 100644 apps/language_server/test/experimental/code_unit_test.exs create mode 100644 apps/language_server/test/experimental/process_cache_test.exs create mode 100644 apps/language_server/test/experimental/project_test.exs create mode 100644 apps/language_server/test/experimental/provider/code_action/#warning_parser.ex# create mode 100644 apps/language_server/test/experimental/provider/code_action/replace_with_underscore_test.exs create mode 100644 apps/language_server/test/experimental/provider/handlers/find_references_test.exs create mode 100644 apps/language_server/test/experimental/provider/handlers/formatting_test.exs create mode 100644 apps/language_server/test/experimental/provider/queue_test.exs create mode 100644 apps/language_server/test/experimental/server/configuration_test.exs create mode 100644 apps/language_server/test/support/experimental/code_mod/code_mod_case.ex 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"}, }