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