From 06178f5cc5babad9522ba1dc67046ffc48a0d6ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Samson?= Date: Fri, 15 Mar 2024 19:38:42 +0100 Subject: [PATCH] OTP 26 Incremental dialyzer (#1081) * fix typo * PoC incremental dialyzer * acquire build lock trigger next analysis * implement suggest contracts * apply some improvements introduced since the initial PoC * add error reporting * add env * update readme * remove not needed erts app * fix suggest contracts * fix diagnostic emit * we no longer consolidate protocols * make old dialyzer tests pass * add tests * add OTP 26 dialyzer options --- README.md | 5 +- .../lib/language_server/dialyzer.ex | 22 +- .../lib/language_server/dialyzer/analyzer.ex | 72 +++- .../lib/language_server/dialyzer/manifest.ex | 2 +- .../dialyzer/success_typings.ex | 1 + .../language_server/dialyzer/supervisor.ex | 5 +- .../lib/language_server/dialyzer/utils.ex | 1 + .../language_server/dialyzer_incremental.ex | 363 ++++++++++++++++ .../lib/language_server/server.ex | 17 +- .../test/dialyzer_incremental_test.exs | 404 ++++++++++++++++++ apps/language_server/test/dialyzer_test.exs | 111 ++--- 11 files changed, 886 insertions(+), 117 deletions(-) create mode 100644 apps/language_server/lib/language_server/dialyzer_incremental.ex create mode 100644 apps/language_server/test/dialyzer_incremental_test.exs diff --git a/README.md b/README.md index fea37340d..eefafd714 100644 --- a/README.md +++ b/README.md @@ -306,8 +306,9 @@ With Dialyzer integration enabled, ElixirLS will build an index of symbols (modu Below is a list of configuration options supported by the ElixirLS language server. Please refer to your editor's documentation to determine how to configure language servers.
-
elixirLS.autoBuild
Trigger ElixirLS build when code is saved.
-
elixirLS.dialyzerEnabled
Run ElixirLS's rapid Dialyzer when code is saved.
+
elixirLS.autoBuild
Trigger ElixirLS build when code is saved
+
elixirLS.dialyzerEnabled
Run ElixirLS's rapid Dialyzer when code is saved
+
elixirLS.incrementalDialyzer
Use OTP incremental dialyzer (available on OTP 26+)
elixirLS.dialyzerWarnOpts
Dialyzer options to enable or disable warnings - See Dialyzer's documentation for options. Note that the race_conditions option is unsupported.
elixirLS.dialyzerFormat
Formatter to use for Dialyzer warnings
elixirLS.envVariables
Environment variables to use for compilation
diff --git a/apps/language_server/lib/language_server/dialyzer.ex b/apps/language_server/lib/language_server/dialyzer.ex index f3d782dbb..1ebd0f0cf 100644 --- a/apps/language_server/lib/language_server/dialyzer.ex +++ b/apps/language_server/lib/language_server/dialyzer.ex @@ -200,7 +200,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer do deps_path: deps_path } - trigger_analyze(state) + maybe_trigger_analyze(state) else state end @@ -268,8 +268,8 @@ defmodule ElixirLS.LanguageServer.Dialyzer do } end - defp trigger_analyze(%{analysis_pid: nil} = state), do: do_analyze(state) - defp trigger_analyze(state), do: state + defp maybe_trigger_analyze(%{analysis_pid: nil} = state), do: do_analyze(state) + defp maybe_trigger_analyze(state), do: state defp update_stale(md5, removed_files, file_changes, timestamp, project_dir, build_path) do prev_paths = Map.keys(md5) |> MapSet.new() @@ -607,7 +607,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer do %Diagnostics{ compiler_name: "ElixirLS Dialyzer", file: source_file, - position: normalize_postion(position), + position: normalize_position(position), message: warning_message(data, warning_format), severity: :warning, details: data @@ -617,16 +617,16 @@ defmodule ElixirLS.LanguageServer.Dialyzer do # up until OTP 23 position was line :: non_negative_integer # starting from OTP 24 it is erl_anno:location() :: line | {line, column} - defp normalize_postion({line, column}) when line > 0 do + def normalize_position({line, column}) when line > 0 do {line, column} end # 0 means unknown line - defp normalize_postion(line) when line >= 0 do + def normalize_position(line) when line >= 0 do line end - defp normalize_postion(position) do + def normalize_position(position) do Logger.warning( "[ElixirLS Dialyzer] dialyzer returned warning with invalid position #{inspect(position)}" ) @@ -634,8 +634,8 @@ defmodule ElixirLS.LanguageServer.Dialyzer do 0 end - defp warning_message({_, _, {warning_name, args}} = raw_warning, warning_format) - when warning_format in ["dialyxir_long", "dialyxir_short"] do + def warning_message({_, _, {warning_name, args}} = raw_warning, warning_format) + when warning_format in ["dialyxir_long", "dialyxir_short"] do format_function = case warning_format do "dialyxir_long" -> :format_long @@ -652,11 +652,11 @@ defmodule ElixirLS.LanguageServer.Dialyzer do end end - defp warning_message(raw_warning, "dialyzer") do + def warning_message(raw_warning, "dialyzer") do dialyzer_raw_warning_message(raw_warning) end - defp warning_message(raw_warning, warning_format) do + def warning_message(raw_warning, warning_format) do Logger.info( "[ElixirLS Dialyzer] Unrecognized dialyzerFormat setting: #{inspect(warning_format)}" <> ", falling back to \"dialyzer\"" diff --git a/apps/language_server/lib/language_server/dialyzer/analyzer.ex b/apps/language_server/lib/language_server/dialyzer/analyzer.ex index 7d653d62f..9c911d744 100644 --- a/apps/language_server/lib/language_server/dialyzer/analyzer.ex +++ b/apps/language_server/lib/language_server/dialyzer/analyzer.ex @@ -9,33 +9,41 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Analyzer do # TODO remove this comment when OTP >= 25 is required # default warns taken from - # https://github.com/erlang/otp/blob/4ed7957623e5ccbd420a09a506bd6bc9930fe93c/lib/dialyzer/src/dialyzer_options.erl#L34 - # macros defined in https://github.com/erlang/otp/blob/4ed7957623e5ccbd420a09a506bd6bc9930fe93c/lib/dialyzer/src/dialyzer.hrl#L36 - # as of OTP 25 + # https://github.com/erlang/otp/blob/928d03e6da416208fce7b9a7dbbfbb4f25d26c37/lib/dialyzer/src/dialyzer_options.erl#L34 + # macros defined in https://github.com/erlang/otp/blob/928d03e6da416208fce7b9a7dbbfbb4f25d26c37/lib/dialyzer/src/dialyzer.hrl#L36 + # as of OTP 26 @default_warns [ - :warn_behaviour, - :warn_bin_construction, - :warn_callgraph, - :warn_contract_range, - :warn_contract_syntax, - :warn_contract_types, - :warn_failing_call, - :warn_fun_app, - :warn_map_construction, - :warn_matching, - :warn_non_proper_list, - :warn_not_called, - :warn_opaque, - :warn_return_no_exit, - :warn_undefined_callbacks - ] + :warn_behaviour, + :warn_bin_construction, + :warn_callgraph, + :warn_contract_range, + :warn_contract_syntax, + :warn_contract_types, + :warn_failing_call, + :warn_fun_app, + :warn_map_construction, + :warn_matching, + :warn_non_proper_list, + :warn_not_called, + :warn_opaque, + :warn_return_no_exit, + :warn_undefined_callbacks + ] ++ + (if String.to_integer(System.otp_release()) >= 26 do + [ + # warn_unknown is enabled by default since OTP 26 + :warn_unknown + ] + else + [] + end) + @non_default_warns [ :warn_contract_not_equal, :warn_contract_subtype, :warn_contract_supertype, :warn_return_only_exit, - :warn_umatched_return, - :warn_unknown + :warn_umatched_return ] ++ (if String.to_integer(System.otp_release()) >= 25 do [ @@ -45,6 +53,17 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Analyzer do ] else [] + end) ++ + (if String.to_integer(System.otp_release()) >= 26 do + [ + # OTP >= 26 options + :warn_overlapping_contract + ] + else + [ + # warn_unknown is enabled by default since OTP 26 + :warn_unknown + ] end) @log_cache_length 10 @@ -162,7 +181,16 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Analyzer do end def matching_tags(warn_opts) do - :dialyzer_options.build_warnings(warn_opts, @default_warns) + default_warns = + unless :persistent_term.get(:language_server_test_mode, false) do + @default_warns + else + # do not include warn_unknown in tests + # we build small PLT and this results in lots of warnings + @default_warns -- [:warn_unknown] + end + + :dialyzer_options.build_warnings(warn_opts, default_warns) end defp main_loop(%__MODULE__{backend_pid: backend_pid} = state) do diff --git a/apps/language_server/lib/language_server/dialyzer/manifest.ex b/apps/language_server/lib/language_server/dialyzer/manifest.ex index c93913d8f..c7a9463f1 100644 --- a/apps/language_server/lib/language_server/dialyzer/manifest.ex +++ b/apps/language_server/lib/language_server/dialyzer/manifest.ex @@ -201,7 +201,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Manifest do end end - defp otp_vsn() do + def otp_vsn() do major = :erlang.system_info(:otp_release) |> List.to_string() vsn_file = Path.join([:code.root_dir(), "releases", major, "OTP_VERSION"]) diff --git a/apps/language_server/lib/language_server/dialyzer/success_typings.ex b/apps/language_server/lib/language_server/dialyzer/success_typings.ex index df7be58e2..471e37056 100644 --- a/apps/language_server/lib/language_server/dialyzer/success_typings.ex +++ b/apps/language_server/lib/language_server/dialyzer/success_typings.ex @@ -10,6 +10,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer.SuccessTypings do |> :dialyzer_plt.all_modules() |> :sets.to_list() + # TODO filter by apps? for mod <- modules, file = source(mod), file in files, diff --git a/apps/language_server/lib/language_server/dialyzer/supervisor.ex b/apps/language_server/lib/language_server/dialyzer/supervisor.ex index 2df7c8942..7e3423731 100644 --- a/apps/language_server/lib/language_server/dialyzer/supervisor.ex +++ b/apps/language_server/lib/language_server/dialyzer/supervisor.ex @@ -1,5 +1,5 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Supervisor do - alias ElixirLS.LanguageServer.Dialyzer + alias ElixirLS.LanguageServer.{Dialyzer, DialyzerIncremental} use Supervisor def start_link(parent \\ self(), root_path) do @@ -10,7 +10,8 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Supervisor do def init({parent, root_path}) do Supervisor.init( [ - {Dialyzer, {parent, root_path}} + {Dialyzer, {parent, root_path}}, + {DialyzerIncremental, {parent, root_path}} ], strategy: :one_for_one ) diff --git a/apps/language_server/lib/language_server/dialyzer/utils.ex b/apps/language_server/lib/language_server/dialyzer/utils.ex index 95af5c274..93bb9a420 100644 --- a/apps/language_server/lib/language_server/dialyzer/utils.ex +++ b/apps/language_server/lib/language_server/dialyzer/utils.ex @@ -51,6 +51,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Utils do defp module_references(mod) do try do for form <- read_forms(mod), + # TODO does import create remote call? {:call, _, {:remote, _, {:atom, _, module}, _}, _} <- form, uniq: true, do: module diff --git a/apps/language_server/lib/language_server/dialyzer_incremental.ex b/apps/language_server/lib/language_server/dialyzer_incremental.ex new file mode 100644 index 000000000..deb4cb62c --- /dev/null +++ b/apps/language_server/lib/language_server/dialyzer_incremental.ex @@ -0,0 +1,363 @@ +defmodule ElixirLS.LanguageServer.DialyzerIncremental do + use GenServer + alias ElixirLS.LanguageServer.{Server, JsonRpc, Diagnostics} + require Logger + require Record + alias ElixirLS.LanguageServer.Dialyzer.{Manifest, Analyzer, SuccessTypings} + alias ElixirLS.LanguageServer.Dialyzer + + defstruct [ + :parent, + :root_path, + :analysis_pid, + :warn_opts, + :warning_format, + :apps_paths, + :project_dir, + :next_build + ] + + Record.defrecordp(:iplt_info, [ + :files, + :mod_deps, + :warning_map, + :legal_warnings + ]) + + def start_link({parent, root_path}) do + GenServer.start_link(__MODULE__, {parent, root_path}, name: {:global, {parent, __MODULE__}}) + end + + def analyze(parent \\ self(), build_ref, warn_opts, warning_format, project_dir) do + GenServer.cast( + {:global, {parent, __MODULE__}}, + {:analyze, build_ref, warn_opts, warning_format, project_dir} + ) + end + + def suggest_contracts(parent \\ self(), files) + + def suggest_contracts(_parent, []), do: [] + + def suggest_contracts(parent, files) do + try do + GenServer.call({:global, {parent, __MODULE__}}, {:suggest_contracts, files}, :infinity) + catch + kind, payload -> + {payload, stacktrace} = Exception.blame(kind, payload, __STACKTRACE__) + error_msg = Exception.format(kind, payload, stacktrace) + + Logger.error("Unable to suggest contracts: #{error_msg}") + [] + end + end + + @impl GenServer + def init({parent, root_path}) do + state = %__MODULE__{parent: parent, root_path: root_path} + + {:ok, state} + end + + @impl GenServer + def terminate(reason, _state) do + case reason do + :normal -> + :ok + + :shutdown -> + :ok + + {:shutdown, _} -> + :ok + + _other -> + ElixirLS.LanguageServer.Server.do_sanity_check() + message = Exception.format_exit(reason) + + JsonRpc.telemetry( + "lsp_server_error", + %{ + "elixir_ls.lsp_process" => inspect(__MODULE__), + "elixir_ls.lsp_server_error" => message + }, + %{} + ) + + Logger.info("Terminating #{__MODULE__}: #{message}") + + JsonRpc.show_message( + :error, + "ElixirLS Dialyzer had an error. If this happens repeatedly, set " <> + "\"elixirLS.dialyzerEnabled\" to false in settings.json to disable it" + ) + end + end + + @impl GenServer + def handle_call({:suggest_contracts, files}, _from, state) do + specs = + try do + # TODO maybe store plt in state? + plt = :dialyzer_iplt.from_file(elixir_incremental_plt_path()) + SuccessTypings.suggest_contracts(plt, files) + catch + :throw = kind, {:dialyzer_error, message} = payload -> + {_payload, stacktrace} = Exception.blame(kind, payload, __STACKTRACE__) + + Logger.warning( + "Unable to load incremental PLT: #{message}\n#{Exception.format_stacktrace(stacktrace)}" + ) + + [] + + kind, payload -> + {payload, stacktrace} = Exception.blame(kind, payload, __STACKTRACE__) + + Logger.error( + "Unexpected error during incremental PLT load: #{Exception.format(kind, payload, stacktrace)}" + ) + + [] + end + + {:reply, specs, state} + end + + @impl GenServer + def handle_cast( + {:analyze, build_ref, warn_opts, warning_format, project_dir}, + %{analysis_pid: nil} = state + ) do + state = + ElixirLS.LanguageServer.Build.with_build_lock(fn -> + if Mix.Project.get() do + parent = self() + + apps_paths = + if Mix.Project.umbrella?() do + Mix.Project.apps_paths() + else + # in umbrella Mix.Project.apps_paths() returns nil + # we add an empty prefix + %{Mix.Project.config()[:app] => ""} + end + + {opts, warning_modules_to_apps} = build_dialyzer_opts() + + {:ok, pid} = + Task.start_link(fn -> + warnings = do_analyze(opts, warning_modules_to_apps) + send(parent, {:analysis_finished, warnings, build_ref}) + end) + + %{ + state + | warn_opts: warn_opts, + warning_format: warning_format, + apps_paths: apps_paths, + project_dir: project_dir, + analysis_pid: pid + } + else + state + end + end) + + {:noreply, state} + end + + def handle_cast({:analyze, _build_ref, _warn_opts, _warning_format} = msg, state) do + # analysis in progress - store last requested build + # we will trigger one more time + {:noreply, %{state | next_build: msg}} + end + + @impl GenServer + def handle_info( + {:analysis_finished, warnings_map, build_ref}, + state + ) do + diagnostics = + to_diagnostics( + warnings_map, + state.warn_opts, + state.warning_format, + state.apps_paths, + state.project_dir + ) + + Server.dialyzer_finished(state.parent, diagnostics, build_ref) + state = %{state | analysis_pid: nil} + + case state.next_build do + nil -> {:noreply, state} + msg -> handle_cast(msg, %{state | next_build: nil}) + end + end + + defp build_dialyzer_opts() do + # assume that all required apps has been loaded during build + # notable exception is erts which is not loaded by default but we load it manually during startup + loaded_apps = + Application.loaded_applications() + |> Enum.map(&elem(&1, 0)) + + all_apps = + loaded_apps + |> Enum.map(&{&1, :code.lib_dir(&1)}) + # reject not loaded + |> Enum.reject(fn {_app, res} -> match?({:error, :bad_name}, res) end) + # reject elixir_ls + |> Enum.reject(fn {app, _res} -> + app in [ + :language_server, + :debug_adapter, + :elixir_ls_utils, + :mix_task_archive_deps, + :jason_v, + :dialyxir_vendored, + :path_glob_vendored, + :elixir_sense, + :erl2ex + ] + end) + # hex is distributed without debug info + |> Enum.reject(fn {app, _res} -> app in [:hex] end) + + files_rec = all_apps |> Keyword.values() |> Enum.map(&:filename.join(&1, ~c"ebin")) + + # we are under build lock - it's safe to call Mix.Project APIs + warning_apps = + if Mix.Project.umbrella?() do + Mix.Project.apps_paths() |> Enum.map(&elem(&1, 0)) + else + # in umbrella Mix.Project.apps_paths() returns nil + # get app from config instead + [Mix.Project.config()[:app]] + end + + warning_modules_to_apps = + for app <- warning_apps, + {:ok, app_modules} = :application.get_key(app, :modules), + module <- app_modules, + into: %{}, + do: {module, app} + + warning_files_rec = + all_apps + |> Keyword.filter(fn {app, _} -> app in warning_apps end) + |> Keyword.values() + |> Enum.map(&:filename.join(&1, ~c"ebin")) + + files_rec = + unless :persistent_term.get(:language_server_test_mode, false) do + files_rec + else + # do not include in PLT OTP and elixir apps in tests + warning_files_rec + end + + opts = [ + analysis_type: :incremental, + files_rec: files_rec, + warning_files_rec: warning_files_rec, + from: :byte_code, + init_plt: elixir_incremental_plt_path() + ] + + {opts, warning_modules_to_apps} + end + + defp elixir_incremental_plt_path() do + [ + File.cwd!(), + ".elixir_ls/iplt-#{Manifest.otp_vsn()}_elixir-#{System.version()}-#{Mix.env()}" + ] + |> Path.join() + |> to_charlist() + end + + defp to_diagnostics(warnings_map, warn_opts, warning_format, apps_paths, project_dir) do + tags_enabled = Analyzer.matching_tags(warn_opts) + + for {app, app_warnings_map} <- warnings_map, + {_module, raw_warnings} <- app_warnings_map, + {tag, {file, position, _m_or_mfa}, msg} <- raw_warnings, + tag in tags_enabled, + warning = {tag, {file, position}, msg}, + app_path = Map.fetch!(apps_paths, app), + source_file = Path.absname(Path.join([app_path, to_string(file)]), project_dir) do + %Diagnostics{ + compiler_name: "ElixirLS Dialyzer", + file: source_file, + position: Dialyzer.normalize_position(position), + message: Dialyzer.warning_message(warning, warning_format), + severity: :warning, + details: warning + } + end + end + + defp do_analyze(opts, warning_modules_to_apps) do + try do + {us, {warnings, changed, analyzed}} = + :timer.tc(fn -> + Logger.info("Updating incremental PLT") + :dialyzer.run_report_modules_changed_and_analyzed(opts) + end) + + changed_info = + case changed do + :undefined -> "" + list -> "changed #{length(list)} modules, " + end + + Logger.info( + "Incremental PLT updated in #{div(us, 1000)}ms, #{changed_info}analyzed #{length(analyzed)}, #{length(warnings)} warnings found" + ) + + # warnings returned by dialyzer public api are stripped to https://www.erlang.org/doc/man/dialyzer#type-dial_warning + # file paths are app relative but we need to know which umbrella app they come from + # we load PLT info directly and read raw warnings + {us, {_dialyzer_plt, plt_info}} = + :timer.tc(fn -> + :dialyzer_iplt.plt_and_info_from_file(elixir_incremental_plt_path()) + end) + + Logger.info("Loaded PLT info in #{div(us, 1000)}ms") + + iplt_info(warning_map: warning_map) = plt_info + # filter by modules from project app/umbrella apps + warning_map + |> Map.take(Map.keys(warning_modules_to_apps)) + |> Enum.group_by( + fn {module, _warnings} -> + Map.fetch!(warning_modules_to_apps, module) + end, + fn {module, warnings} -> + # raw warnings may be duplicated + {module, Enum.uniq(warnings)} + end + ) + catch + :throw = kind, {:dialyzer_error, message} = payload -> + {_payload, stacktrace} = Exception.blame(kind, payload, __STACKTRACE__) + + Logger.error( + "Dialyzer error during incremental PLT build: #{message}\n#{Exception.format_stacktrace(stacktrace)}" + ) + + [] + + kind, payload -> + {payload, stacktrace} = Exception.blame(kind, payload, __STACKTRACE__) + + Logger.error( + "Unexpected error during incremental PLT build: #{Exception.format(kind, payload, stacktrace)}" + ) + + [] + end + end +end diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index 74c1a44bd..a3ef09fd7 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -24,6 +24,7 @@ defmodule ElixirLS.LanguageServer.Server do Protocol, JsonRpc, Dialyzer, + DialyzerIncremental, Diagnostics, MixProjectCache, Parser @@ -254,7 +255,7 @@ defmodule ElixirLS.LanguageServer.Server do parent = self() spawn(fn -> - contracts = Dialyzer.suggest_contracts(parent, [abs_path]) + contracts = dialyzer_module(state.settings).suggest_contracts(parent, [abs_path]) GenServer.reply(from, contracts) end) @@ -1508,7 +1509,7 @@ defmodule ElixirLS.LanguageServer.Server do (state.settings["dialyzerWarnOpts"] || []) |> Enum.map(&String.to_atom/1) - Dialyzer.analyze( + dialyzer_module(state.settings).analyze( state.build_ref, warn_opts, dialyzer_default_format(state), @@ -1580,7 +1581,7 @@ defmodule ElixirLS.LanguageServer.Server do contracts_by_file = not_dirty |> Enum.map(fn {_from, uri} -> SourceFile.Path.from_uri(uri) end) - |> then(fn uris -> Dialyzer.suggest_contracts(parent, uris) end) + |> then(fn uris -> dialyzer_module(state.settings).suggest_contracts(parent, uris) end) |> Enum.group_by(fn {file, _, _, _, _} -> file end) for {from, uri} <- not_dirty do @@ -2288,6 +2289,16 @@ defmodule ElixirLS.LanguageServer.Server do end end + defp dialyzer_module(settings) do + otp_release = String.to_integer(System.otp_release()) + + if otp_release >= 26 and Map.get(settings, "incrementalDialyzer", true) do + DialyzerIncremental + else + Dialyzer + end + end + def do_sanity_check(message \\ nil) do try do if message != nil and String.contains?(message, "UndefinedFunctionError") do diff --git a/apps/language_server/test/dialyzer_incremental_test.exs b/apps/language_server/test/dialyzer_incremental_test.exs new file mode 100644 index 000000000..825b71f2f --- /dev/null +++ b/apps/language_server/test/dialyzer_incremental_test.exs @@ -0,0 +1,404 @@ +if System.otp_release() |> String.to_integer() >= 26 do + defmodule ElixirLS.LanguageServer.DialyzerIncrementalTest do + alias ElixirLS.LanguageServer.{ + Dialyzer, + Server, + Protocol, + SourceFile, + JsonRpc, + Tracer, + Build, + MixProjectCache, + Parser + } + + import ElixirLS.LanguageServer.Test.ServerTestHelpers + use ElixirLS.Utils.MixTest.Case, async: false + use Protocol + + setup_all do + compiler_options = Code.compiler_options() + Build.set_compiler_options() + + on_exit(fn -> + Code.compiler_options(compiler_options) + end) + + {:ok, %{}} + end + + setup do + {:ok, server} = Server.start_link() + {:ok, _} = start_supervised(MixProjectCache) + {:ok, _} = start_supervised(Parser) + start_server(server) + + {:ok, _tracer} = start_supervised(Tracer) + + on_exit(fn -> + if Process.alive?(server) do + Process.monitor(server) + GenServer.stop(server) + + receive do + {:DOWN, _, _, ^server, _} -> + :ok + end + else + :ok + end + end) + + {:ok, %{server: server}} + end + + @tag slow: true, fixture: true + test "reports diagnostics then clears them once problems are fixed", %{server: server} do + in_fixture(__DIR__, "dialyzer", fn -> + file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) + + initialize(server, %{"dialyzerEnabled" => true, "dialyzerFormat" => "dialyxir_long"}) + + message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 + + assert publish_diagnostics_notif(^file_a, [ + %{ + "message" => error_message1, + "range" => %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, + "severity" => 2, + "source" => "ElixirLS Dialyzer" + }, + %{ + "message" => error_message2, + "range" => %{ + "end" => %{"character" => 4, "line" => 2}, + "start" => %{"character" => 4, "line" => 2} + }, + "severity" => 2, + "source" => "ElixirLS Dialyzer" + } + ]) = message + + assert error_message1 == "Function fun/0 has no local return." + + assert error_message2 == + "The pattern can never match the type.\n\nPattern:\n:ok\n\nType:\n:error\n" + + # Fix file B. It should recompile and re-analyze A and B only + b_text = """ + defmodule B do + def fun do + :ok + end + end + """ + + b_uri = SourceFile.Path.to_uri("lib/b.ex") + Server.receive_packet(server, did_open(b_uri, "elixir", 1, b_text)) + Process.sleep(1500) + File.write!("lib/b.ex", b_text) + + Server.receive_packet(server, did_save(b_uri)) + + assert_receive publish_diagnostics_notif(^file_a, []), 10000 + + assert_receive notification("window/logMessage", %{ + "message" => "Dialyzer analysis is up to date" + }), + 10000 + + wait_until_compiled(server) + end) + end + + @tag slow: true, fixture: true + test "reports dialyxir_long formatted error", %{server: server} do + in_fixture(__DIR__, "dialyzer", fn -> + file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) + + initialize(server, %{"dialyzerEnabled" => true, "dialyzerFormat" => "dialyxir_long"}) + + message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 + + assert publish_diagnostics_notif(^file_a, [ + %{ + "message" => error_message1, + "range" => %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, + "severity" => 2, + "source" => "ElixirLS Dialyzer" + }, + %{ + "message" => error_message2, + "range" => %{ + "end" => %{"character" => 4, "line" => 2}, + "start" => %{"character" => 4, "line" => 2} + }, + "severity" => 2, + "source" => "ElixirLS Dialyzer" + } + ]) = message + + assert error_message1 == "Function fun/0 has no local return." + + assert error_message2 == """ + The pattern can never match the type. + + Pattern: + :ok + + Type: + :error + """ + + wait_until_compiled(server) + end) + end + + @tag slow: true, fixture: true + test "reports dialyxir_short formatted error", %{server: server} do + in_fixture(__DIR__, "dialyzer", fn -> + file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) + + initialize(server, %{"dialyzerEnabled" => true, "dialyzerFormat" => "dialyxir_short"}) + + message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 + + assert publish_diagnostics_notif(^file_a, [ + %{ + "message" => error_message1, + "range" => %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, + "severity" => 2, + "source" => "ElixirLS Dialyzer" + }, + %{ + "message" => error_message2, + "range" => %{ + "end" => %{"character" => 4, "line" => 2}, + "start" => %{"character" => 4, "line" => 2} + }, + "severity" => 2, + "source" => "ElixirLS Dialyzer" + } + ]) = message + + assert error_message1 == "Function fun/0 has no local return." + assert error_message2 == "The pattern can never match the type :error." + wait_until_compiled(server) + end) + end + + @tag slow: true, fixture: true + test "reports dialyzer formatted error", %{server: server} do + in_fixture(__DIR__, "dialyzer", fn -> + file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) + + initialize(server, %{"dialyzerEnabled" => true, "dialyzerFormat" => "dialyzer"}) + + message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 + + assert publish_diagnostics_notif(^file_a, [ + %{ + "message" => error_message1, + "range" => %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, + "severity" => 2, + "source" => "ElixirLS Dialyzer" + }, + %{ + "message" => _error_message2, + "range" => %{ + "end" => %{"character" => 4, "line" => 2}, + "start" => %{"character" => 4, "line" => 2} + }, + "severity" => 2, + "source" => "ElixirLS Dialyzer" + } + ]) = message + + assert error_message1 == "Function 'fun'/0 has no local return" + + # Note: Don't assert on error_message 2 because the message is not stable across OTP versions + wait_until_compiled(server) + end) + end + + @tag slow: true, fixture: true + test "reports dialyxir_short error in umbrella", %{server: server} do + in_fixture(__DIR__, "umbrella_dialyzer", fn -> + file_a = SourceFile.Path.to_uri(Path.absname("apps/app1/lib/app1.ex")) + + initialize(server, %{"dialyzerEnabled" => true, "dialyzerFormat" => "dialyxir_short"}) + + message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 + + assert publish_diagnostics_notif(^file_a, [ + %{ + "message" => error_message1, + "range" => %{ + "end" => %{"character" => 2, "line" => 1}, + "start" => %{"character" => 2, "line" => 1} + }, + "severity" => 2, + "source" => "ElixirLS Dialyzer" + }, + %{ + "message" => error_message2, + "range" => %{ + "end" => %{"character" => 4, "line" => 2}, + "start" => %{"character" => 4, "line" => 2} + }, + "severity" => 2, + "source" => "ElixirLS Dialyzer" + } + ]) = message + + assert error_message1 == "Function check_error/0 has no local return." + assert error_message2 == "The pattern can never match the type :error." + wait_until_compiled(server) + end) + end + + test "clears diagnostics when source files are deleted", %{server: server} do + in_fixture(__DIR__, "dialyzer", fn -> + file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) + + initialize(server, %{"dialyzerEnabled" => true}) + + assert_receive publish_diagnostics_notif(^file_a, [_, _]), 20000 + + # Delete file, warning diagnostics should be cleared + File.rm("lib/a.ex") + Server.receive_packet(server, did_change_watched_files([%{"uri" => file_a, "type" => 3}])) + assert_receive publish_diagnostics_notif(^file_a, []), 20000 + wait_until_compiled(server) + end) + end + + @tag slow: true, fixture: true + test "do not suggests contracts if not enabled", %{server: server} do + in_fixture(__DIR__, "dialyzer", fn -> + file_c = SourceFile.Path.to_uri(Path.absname("lib/c.ex")) + + initialize(server, %{ + "dialyzerEnabled" => true, + "dialyzerFormat" => "dialyxir_long", + "suggestSpecs" => false + }) + + message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 + + assert publish_diagnostics_notif(_, _) = message + + Server.receive_packet( + server, + did_open(file_c, "elixir", 2, File.read!(Path.absname("lib/c.ex"))) + ) + + Server.receive_packet( + server, + code_lens_req(3, file_c) + ) + + resp = assert_receive(%{"id" => 3}, 5000) + + assert response(3, []) == resp + wait_until_compiled(server) + end) + end + + @tag slow: true, fixture: true + test "suggests contracts if enabled and applies suggestion", %{server: server} do + in_fixture(__DIR__, "dialyzer", fn -> + file_c = SourceFile.Path.to_uri(Path.absname("lib/c.ex")) + + initialize(server, %{ + "dialyzerEnabled" => true, + "dialyzerFormat" => "dialyxir_long", + "suggestSpecs" => true + }) + + message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 + + assert publish_diagnostics_notif(_, _) = message + + Server.receive_packet( + server, + did_open(file_c, "elixir", 2, File.read!(Path.absname("lib/c.ex"))) + ) + + Server.receive_packet( + server, + code_lens_req(3, file_c) + ) + + resp = assert_receive(%{"id" => 3}, 5000) + + assert response(3, [ + %{ + "command" => %{ + "arguments" => + args = [ + %{ + "arity" => 0, + "fun" => "myfun", + "line" => 2, + "mod" => "Elixir.C", + "spec" => "myfun() :: 1", + "uri" => ^file_c + } + ], + "command" => command = "spec:" <> _, + "title" => "@spec myfun() :: 1" + }, + "range" => %{ + "end" => %{"character" => 0, "line" => 1}, + "start" => %{"character" => 0, "line" => 1} + } + } + ]) = resp + + Server.receive_packet( + server, + execute_command_req(4, command, args) + ) + + assert_receive(%{ + "id" => id, + "method" => "workspace/applyEdit", + "params" => %{ + "edit" => %{ + "changes" => %{ + ^file_c => [ + %{ + "newText" => " @spec myfun() :: 1\n", + "range" => %{ + "end" => %{"character" => 0, "line" => 1}, + "start" => %{"character" => 0, "line" => 1} + } + } + ] + } + }, + "label" => "Add @spec to Elixir.C.myfun/0" + } + }) + + JsonRpc.receive_packet(response(id, %{"applied" => true})) + + assert_receive(%{"id" => 4, "result" => nil}, 5000) + wait_until_compiled(server) + end) + end + end +end diff --git a/apps/language_server/test/dialyzer_test.exs b/apps/language_server/test/dialyzer_test.exs index 45194f295..05ed22cee 100644 --- a/apps/language_server/test/dialyzer_test.exs +++ b/apps/language_server/test/dialyzer_test.exs @@ -61,7 +61,11 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do in_fixture(__DIR__, "dialyzer", fn -> file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) - initialize(server, %{"dialyzerEnabled" => true, "dialyzerFormat" => "dialyxir_long"}) + initialize(server, %{ + "dialyzerEnabled" => true, + "dialyzerFormat" => "dialyxir_long", + "incrementalDialyzer" => false + }) message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 @@ -121,7 +125,11 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do @tag slow: true, fixture: true test "only analyzes the changed files", %{server: server} do in_fixture(__DIR__, "dialyzer", fn -> - initialize(server, %{"dialyzerEnabled" => true, "dialyzerFormat" => "dialyxir_long"}) + initialize(server, %{ + "dialyzerEnabled" => true, + "dialyzerFormat" => "dialyxir_long", + "incrementalDialyzer" => false + }) assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20_000 @@ -169,7 +177,11 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do in_fixture(__DIR__, "dialyzer", fn -> file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) - initialize(server, %{"dialyzerEnabled" => true, "dialyzerFormat" => "dialyxir_long"}) + initialize(server, %{ + "dialyzerEnabled" => true, + "dialyzerFormat" => "dialyxir_long", + "incrementalDialyzer" => false + }) message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 @@ -215,7 +227,11 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do in_fixture(__DIR__, "dialyzer", fn -> file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) - initialize(server, %{"dialyzerEnabled" => true, "dialyzerFormat" => "dialyxir_short"}) + initialize(server, %{ + "dialyzerEnabled" => true, + "dialyzerFormat" => "dialyxir_short", + "incrementalDialyzer" => false + }) message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 @@ -251,7 +267,11 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do in_fixture(__DIR__, "dialyzer", fn -> file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) - initialize(server, %{"dialyzerEnabled" => true, "dialyzerFormat" => "dialyzer"}) + initialize(server, %{ + "dialyzerEnabled" => true, + "dialyzerFormat" => "dialyzer", + "incrementalDialyzer" => false + }) message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 @@ -288,7 +308,11 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do in_fixture(__DIR__, "umbrella_dialyzer", fn -> file_a = SourceFile.Path.to_uri(Path.absname("apps/app1/lib/app1.ex")) - initialize(server, %{"dialyzerEnabled" => true, "dialyzerFormat" => "dialyxir_short"}) + initialize(server, %{ + "dialyzerEnabled" => true, + "dialyzerFormat" => "dialyxir_short", + "incrementalDialyzer" => false + }) message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 @@ -323,7 +347,7 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do in_fixture(__DIR__, "dialyzer", fn -> file_a = SourceFile.Path.to_uri(Path.absname("lib/a.ex")) - initialize(server, %{"dialyzerEnabled" => true}) + initialize(server, %{"dialyzerEnabled" => true, "incrementalDialyzer" => false}) assert_receive publish_diagnostics_notif(^file_a, [_, _]), 20000 @@ -335,73 +359,6 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do end) end - @tag slow: true, fixture: true - test "protocol rebuild does not trigger consolidation warnings", %{server: server} do - in_fixture(__DIR__, "protocols", fn -> - uri = SourceFile.Path.to_uri(Path.absname("lib/implementations.ex")) - - initialize(server, %{"dialyzerEnabled" => true}) - - assert_receive notification("window/logMessage", %{"message" => "Compile took" <> _}), 5000 - - assert_receive notification("window/logMessage", %{ - "message" => "[ElixirLS Dialyzer] Done writing manifest" <> _ - }), - 30000 - - v2_text = """ - defimpl Protocols.Example, for: List do - def some(t), do: t - end - - defimpl Protocols.Example, for: String do - def some(t), do: t - end - - defimpl Protocols.Example, for: Map do - def some(t), do: t - end - """ - - Server.receive_packet(server, did_open(uri, "elixir", 1, v2_text)) - File.write!("lib/implementations.ex", v2_text) - Server.receive_packet(server, did_save(uri)) - - assert_receive notification("window/logMessage", %{"message" => "Compile took" <> _}), 5000 - - Process.sleep(2000) - - v2_text = """ - defimpl Protocols.Example, for: List do - def some(t), do: t - end - - defimpl Protocols.Example, for: String do - def some(t), do: t - end - - defimpl Protocols.Example, for: Map do - def some(t), do: t - end - - defimpl Protocols.Example, for: Atom do - def some(t), do: t - end - """ - - File.write!("lib/implementations.ex", v2_text) - Server.receive_packet(server, did_save(uri)) - - assert_receive notification("window/logMessage", %{"message" => "Compile took" <> _}), 5000 - - # we should not receive Protocol has already been consolidated warnings here - refute_receive notification("textDocument/publishDiagnostics", %{"diagnostics" => [_ | _]}), - 3000 - - wait_until_compiled(server) - end) - end - @tag slow: true, fixture: true test "do not suggests contracts if not enabled", %{server: server} do in_fixture(__DIR__, "dialyzer", fn -> @@ -410,7 +367,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do initialize(server, %{ "dialyzerEnabled" => true, "dialyzerFormat" => "dialyxir_long", - "suggestSpecs" => false + "suggestSpecs" => false, + "incrementalDialyzer" => false }) message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000 @@ -442,7 +400,8 @@ defmodule ElixirLS.LanguageServer.DialyzerTest do initialize(server, %{ "dialyzerEnabled" => true, "dialyzerFormat" => "dialyxir_long", - "suggestSpecs" => true + "suggestSpecs" => true, + "incrementalDialyzer" => false }) message = assert_receive %{"method" => "textDocument/publishDiagnostics"}, 20000