Compare commits

...
19 Commits
Author SHA1 Message Date
jamauro 372836d623 Change variable from bk to bucket (#13921)
Simple change from `bk` to `bucket` for consistency with the rest of the doc
2024-10-21 18:00:21 +02:00
Jean Klingler 444cd7e6bd Fix crash in Macro.to_string/1 on invalid sigils (#13905) 2024-10-17 15:39:57 +09:00
Jean Klingler cacb175ab3 Add @doc false to Inspect.Algebra.no_limit/1 (#13893) 2024-10-10 18:21:49 +09:00
Jean Klingler bfce3ed7a9 Fix another dialyzer Kernel.with edge case (#13869)
Close https://github.com/elixir-lang/elixir/issues/13868
2024-09-30 20:11:05 +02:00
Vasily Suvorov 06811cbdc8 Fix typedoc for init_option (#13870)
Change `c:init/1` to`init/2` in the `typedoc`'s text
2024-09-30 15:41:35 +02:00
Igor Goryachev 7a6b35422c Fix logger translator tests under Erlang 27.1 (#13839) 2024-09-21 13:36:09 +02:00
José Valim 78f63d0831 Release v1.17.3 2024-09-18 14:33:39 +02:00
Urmas Talimaa 86ec46a5c4 Fix parsing of fractional durations (#13832)
The parsing of fractional durations checked for non-negativity by
testing second > 0, which reports false for not only negative integers
but also for 0.

Note that changing `if second > 0` to `if second >= 0` would fix
behaviour for "PT0,6S", but would break "PT-0,6S".
2024-09-18 09:03:51 +02:00
José Valim c99631876c Exclude latest tags 2024-09-17 18:01:17 +02:00
José Valim 46815ef554 Fix docs node generation 2024-09-17 16:40:01 +02:00
José Valim 256a55a53e Do not verify modules twice 2024-09-17 16:22:38 +02:00
Jean Klingler a78c92043f Fix: Macro.expand/2 should not expand __ENV__ on :match context (#13807) 2024-09-06 17:58:28 +09:00
José Valim b3bfa519a7 Pass proper path to consolidation in IEx recompile 2024-08-20 11:15:30 +02:00
Steve Cohen b734da3f62 Ensure :native is a valid parameter type (#13781)
Several of the functions in System that deal with time had a typespec
that only allowed the time_unit type even though they accepted the
:native time unit. This confuses dialyzer if :native is passed to
these functions
2024-08-17 10:53:36 +09:00
José Valim 10f86595b5 Raise clearer error message if struct is missing on type checker 2024-08-09 13:05:28 +02:00
José Valim 1da385776f More docs 2024-08-07 01:06:57 +02:00
José Valim a45e08eeda Keep labels with at least one occurrence on xref graph cycles and stats.
Closes #13762.
2024-08-07 00:57:21 +02:00
Artur Plysiuk bb61b953d7 Fix spec for to_timeout/1 (#13755) 2024-08-02 12:15:17 +02:00
José Valim 1e126e88b7 Do not attempt to group module warnings with context
Otherwise, the larger the context, the more expensive
grouping the warnings would get.

Closes #13742
2024-07-28 20:12:15 +02:00
28 changed files with 536 additions and 194 deletions
+18
View File
@@ -70,6 +70,24 @@ Finally, a new `Kernel.to_timeout/1` function has been added, which helps develo
Process.send_after(pid, :wake_up, to_timeout(hour: 1))
```
## v1.17.3 (2024-09-18)
### 1. Bug fixes
#### Elixir
* [Duration] Fix parsing of fractional durations with non-positive seconds
* [Kernel] Do not attempt to group module warnings when they have a large context
#### IEx
* [IEx.Helpers] Properly reconsolidate protocols on `recompile`
#### Mix
* [mix compile.elixir] Do not verify modules twice
* [mix xref] Respect the `--label` option on stats and cycles
## v1.17.2 (2024-07-06)
### 1. Bug fixes
+4 -4
View File
@@ -10,13 +10,13 @@
4. Commit changes above with title "Release vVERSION" and push it
6. Once GitHub actions completes, generate a new tag, and push it
5. Once GitHub actions completes, generate a new tag, and push it
7. Wait until GitHub Actions publish artifacts to the draft release
6. Wait until GitHub Actions publish artifacts to the draft release
8. Copy the relevant bits from /CHANGELOG.md to the GitHub release and publish it (link to the announcement if there is one)
7. Copy the relevant bits from /CHANGELOG.md to the GitHub release and publish it (link to the announcement if there is one)
9. Update `_data/elixir-versions.yml` (except for RCs) in `elixir-lang/elixir-lang.github.com`
8. Update `_data/elixir-versions.yml` (except for RCs) in `elixir-lang/elixir-lang.github.com`
## Creating a new vMAJOR.MINOR branch (before first rc)
+1 -1
View File
@@ -1 +1 @@
1.17.2
1.17.3
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
set -e
ELIXIR_VERSION=1.17.2
ELIXIR_VERSION=1.17.3
if [ $# -eq 0 ] || { [ $# -eq 1 ] && { [ "$1" = "--help" ] || [ "$1" = "-h" ]; }; }; then
cat <<USAGE >&2
+1 -1
View File
@@ -1,6 +1,6 @@
@echo off
set ELIXIR_VERSION=1.17.2
set ELIXIR_VERSION=1.17.3
if ""%1""=="""" if ""%2""=="""" goto documentation
if /I ""%1""==""--help"" if ""%2""=="""" goto documentation
+9 -1
View File
@@ -715,7 +715,15 @@ defmodule Calendar.ISO do
{second, <<delimiter, _::binary>> = rest} when delimiter in [?., ?,] ->
case parse_microsecond(rest) do
{{ms, precision}, "S"} ->
ms = if second > 0, do: ms, else: -ms
ms =
case string do
"-" <> _ ->
-ms
_ ->
ms
end
{:ok, [second: second, microsecond: {ms, precision}] ++ acc}
_ ->
+10 -9
View File
@@ -174,16 +174,17 @@ defmodule Code.Normalizer do
# Sigils
defp do_normalize({sigil, meta, [{:<<>>, _, args} = string, modifiers]} = quoted, state)
when is_list(args) and is_atom(sigil) do
case Atom.to_string(sigil) do
"sigil_" <> _ ->
meta =
meta
|> patch_meta_line(state.parent_meta)
|> Keyword.put_new(:delimiter, "\"")
{sigil, meta, [do_normalize(string, %{state | parent_meta: meta}), modifiers]}
when is_atom(sigil) and is_list(args) and is_list(modifiers) do
with "sigil_" <> _ <- Atom.to_string(sigil),
true <- binary_interpolated?(args),
true <- List.ascii_printable?(modifiers) do
meta =
meta
|> patch_meta_line(state.parent_meta)
|> Keyword.put_new(:delimiter, "\"")
{sigil, meta, [do_normalize(string, %{state | parent_meta: meta}), modifiers]}
else
_ ->
normalize_call(quoted, state)
end
+15
View File
@@ -588,6 +588,21 @@ defmodule Inspect.Algebra do
doc_cons(doc1, doc2)
end
@doc ~S"""
Disable any rendering limit while rendering the given document.
## Examples
iex> doc = Inspect.Algebra.glue("hello", "world") |> Inspect.Algebra.group()
iex> Inspect.Algebra.format(doc, 10)
["hello", "\n", "world"]
iex> doc = Inspect.Algebra.no_limit(doc)
iex> Inspect.Algebra.format(doc, 10)
["hello", " ", "world"]
"""
@doc since: "1.14.0"
@spec no_limit(t) :: t
def no_limit(doc) do
doc_limit(doc, :infinity)
end
+2 -3
View File
@@ -6197,9 +6197,8 @@ defmodule Kernel do
"""
@doc since: "1.17.0"
@spec to_timeout([component, ...] | timeout() | Duration.t()) :: timeout()
when component: [{unit, non_neg_integer()}, ...],
unit: :week | :day | :hour | :minute | :second | :millisecond
@spec to_timeout([{unit, non_neg_integer()}] | timeout() | Duration.t()) :: timeout()
when unit: :week | :day | :hour | :minute | :second | :millisecond
def to_timeout(duration)
def to_timeout(:infinity), do: :infinity
+2 -2
View File
@@ -1832,13 +1832,13 @@ defmodule Macro do
defp do_expand_once({:__DIR__, _, atom}, env) when is_atom(atom),
do: {:filename.dirname(env.file), true}
defp do_expand_once({:__ENV__, _, atom}, env) when is_atom(atom) do
defp do_expand_once({:__ENV__, _, atom}, env) when is_atom(atom) and env.context != :match do
env = update_in(env.versioned_vars, &maybe_escape_map/1)
{maybe_escape_map(env), true}
end
defp do_expand_once({{:., _, [{:__ENV__, _, atom}, field]}, _, []} = original, env)
when is_atom(atom) and is_atom(field) do
when is_atom(atom) and is_atom(field) and env.context != :match do
if Map.has_key?(env, field) do
{maybe_escape_map(Map.get(env, field)), true}
else
+25 -9
View File
@@ -291,18 +291,34 @@ defmodule Module.ParallelChecker do
## Warning helpers
defp group_warnings(warnings) do
warnings
|> Enum.reduce(%{}, fn {module, warning, location}, acc ->
locations = MapSet.new([location])
Map.update(acc, {module, warning}, locations, &MapSet.put(&1, location))
end)
|> Enum.map(fn {{module, warning}, locations} -> {module, warning, Enum.sort(locations)} end)
|> Enum.sort()
{ungrouped, grouped} =
Enum.reduce(warnings, {[], %{}}, fn {module, warning, location}, {ungrouped, grouped} ->
%{message: _} = diagnostic = module.format_diagnostic(warning)
if Map.get(diagnostic, :group, false) do
locations = MapSet.new([location])
grouped =
Map.update(grouped, warning, {locations, diagnostic}, fn
{locations, diagnostic} -> {MapSet.put(locations, location), diagnostic}
end)
{ungrouped, grouped}
else
{[{[location], diagnostic} | ungrouped], grouped}
end
end)
grouped =
Enum.map(grouped, fn {_warning, {locations, diagnostic}} ->
{Enum.sort(locations), diagnostic}
end)
Enum.sort(ungrouped ++ grouped)
end
defp emit_warnings(warnings, log?) do
Enum.flat_map(warnings, fn {module, warning, locations} ->
%{message: _} = diagnostic = module.format_diagnostic(warning)
Enum.flat_map(warnings, fn {locations, diagnostic} ->
diagnostics = Enum.map(locations, &to_diagnostic(diagnostic, &1))
log? and print_diagnostics(diagnostics)
diagnostics
+16 -6
View File
@@ -176,10 +176,15 @@ defmodule Module.Types.Of do
# TODO: Use the struct default values to define the default types.
def struct(struct, args_types, default_handling, meta, stack, context) do
context = remote(struct, :__struct__, 0, meta, stack, context)
info =
struct.__info__(:struct) ||
raise "expected #{inspect(struct)} to return struct metadata, but got none"
term = term()
defaults =
for %{field: field} <- struct.__info__(:struct), field != :__struct__ do
for %{field: field} <- info, field != :__struct__ do
{field, term}
end
@@ -700,14 +705,16 @@ defmodule Module.Types.Of do
" is undefined (module ",
inspect(module),
" is not available or is yet to be defined)"
])
]),
group: true
}
end
def format_diagnostic({:undefined_function, module, :__struct__, 0, _exports}) do
%{
message:
"struct #{inspect(module)} is undefined (there is such module but it does not define a struct)"
"struct #{inspect(module)} is undefined (there is such module but it does not define a struct)",
group: true
}
end
@@ -718,7 +725,8 @@ defmodule Module.Types.Of do
Exception.format_mfa(module, fun, arity),
" is undefined or private",
UndefinedFunctionError.hint_for_loaded_module(module, fun, arity, exports)
])
]),
group: true
}
end
@@ -729,7 +737,8 @@ defmodule Module.Types.Of do
Exception.format_mfa(module, fun, arity),
" is deprecated. ",
reason
])
]),
group: true
}
end
@@ -741,7 +750,8 @@ defmodule Module.Types.Of do
inspect(module),
" before invoking the macro ",
Exception.format_mfa(module, fun, arity)
])
]),
group: true
}
end
+1 -1
View File
@@ -595,7 +595,7 @@ defmodule Supervisor do
@typedoc "The supervisor reference."
@type supervisor :: pid | name | {atom, node}
@typedoc "Options given to `start_link/2` and `c:init/1`."
@typedoc "Options given to `start_link/2` and `init/2`."
@type init_option ::
{:strategy, strategy}
| {:max_restarts, non_neg_integer}
+3 -3
View File
@@ -1239,7 +1239,7 @@ defmodule System do
This time is monotonically increasing and starts in an unspecified
point in time.
"""
@spec monotonic_time(time_unit) :: integer
@spec monotonic_time(time_unit | :native) :: integer
def monotonic_time(unit) do
:erlang.monotonic_time(normalize_time_unit(unit))
end
@@ -1265,7 +1265,7 @@ defmodule System do
case of time warps although the VM works towards aligning
them. This time is not monotonic.
"""
@spec system_time(time_unit) :: integer
@spec system_time(time_unit | :native) :: integer
def system_time(unit) do
:erlang.system_time(normalize_time_unit(unit))
end
@@ -1316,7 +1316,7 @@ defmodule System do
`monotonic_time/1`), gives the Erlang system time that corresponds
to that monotonic time.
"""
@spec time_offset(time_unit) :: integer
@spec time_offset(time_unit | :native) :: integer
def time_offset(unit) do
:erlang.time_offset(normalize_time_unit(unit))
end
+1 -1
View File
@@ -124,7 +124,7 @@ iex> {:ok, registry} = GenServer.start_link(KV.Registry, :ok)
{:ok, #PID<0.136.0>}
iex> GenServer.cast(registry, {:create, "shopping"})
:ok
iex> {:ok, bk} = GenServer.call(registry, {:lookup, "shopping"})
iex> {:ok, bucket} = GenServer.call(registry, {:lookup, "shopping"})
{:ok, #PID<0.174.0>}
```
+7 -4
View File
@@ -5,10 +5,13 @@
skipped = Version.parse!("1.0.3")
list_contents =
text_tags
|> String.split()
|> Enum.map(fn "v" <> rest -> Version.parse!(rest) end)
|> Enum.filter(&(Version.compare(&1, skipped) == :gt))
for(
"v" <> rest <- String.split(text_tags),
not String.ends_with?(rest, "-latest"),
version = Version.parse!(rest),
Version.compare(version, skipped) == :gt,
do: version
)
|> Enum.sort({:desc, Version})
|> Enum.map_intersperse(", ", fn version ->
version_string = Version.to_string(version)
+1 -1
View File
@@ -426,7 +426,7 @@ translate_with_else(Meta, [{'else', Else}], S) ->
Clauses = elixir_erl_clauses:get_clauses('else', [{'else', Else ++ [RaiseClause]}], match),
{TranslatedClauses, SC} = elixir_erl_clauses:clauses(Clauses, SV#elixir_erl{extra=pin_guard}),
with_else_closure(Meta, TranslatedClauses, SC#elixir_erl{extra=SV#elixir_erl.extra}).
with_else_closure(Generated, TranslatedClauses, SC#elixir_erl{extra=SV#elixir_erl.extra}).
with_else_closure(Meta, TranslatedClauses, S) ->
Ann = ?ann(Meta),
@@ -237,8 +237,10 @@ defmodule DurationTest do
assert Duration.from_iso8601("P4Y2W3Y") == {:error, :invalid_date_component}
assert Duration.from_iso8601("P5HT4MT3S") == {:error, :invalid_date_component}
assert Duration.from_iso8601("P5H3HT4M") == {:error, :invalid_date_component}
assert Duration.from_iso8601("P0.5Y") == {:error, :invalid_date_component}
assert Duration.from_iso8601("PT1D") == {:error, :invalid_time_component}
assert Duration.from_iso8601("PT.6S") == {:error, :invalid_time_component}
assert Duration.from_iso8601("PT0.5H") == {:error, :invalid_time_component}
assert Duration.from_iso8601("invalid") == {:error, :invalid_duration}
end
@@ -262,6 +264,9 @@ defmodule DurationTest do
assert Duration.from_iso8601!("PT6S") == %Duration{second: 6}
assert Duration.from_iso8601!("PT1,6S") == %Duration{second: 1, microsecond: {600_000, 1}}
assert Duration.from_iso8601!("PT-1.6S") == %Duration{second: -1, microsecond: {-600_000, 1}}
assert Duration.from_iso8601!("PT0,6S") == %Duration{second: 0, microsecond: {600_000, 1}}
assert Duration.from_iso8601!("PT-0,6S") == %Duration{second: 0, microsecond: {-600_000, 1}}
assert Duration.from_iso8601!("-PT-0,6S") == %Duration{second: 0, microsecond: {600_000, 1}}
assert Duration.from_iso8601!("-P10DT4H") == %Duration{day: -10, hour: -4}
assert Duration.from_iso8601!("-P10DT-4H") == %Duration{day: -10, hour: 4}
assert Duration.from_iso8601!("P-10D") == %Duration{day: -10}
@@ -172,6 +172,20 @@ defmodule Code.Normalizer.QuotedASTTest do
) == ~s[~S"""\n"123"\n"""]
end
test "regression: invalid sigil calls" do
assert quoted_to_string(quote do: sigil_r(<<"foo", 123>>, [])) ==
"sigil_r(<<\"foo\", 123>>, [])"
assert quoted_to_string(quote do: sigil_r(<<"foo">>, :invalid_modifiers)) ==
"sigil_r(\"foo\", :invalid_modifiers)"
assert quoted_to_string(quote do: sigil_r(<<"foo">>, [:invalid_modifier])) ==
"sigil_r(\"foo\", [:invalid_modifier])"
assert quoted_to_string(quote do: sigil_r(<<"foo">>, [])) == "~r\"foo\""
assert quoted_to_string(quote do: sigil_r(<<"foo">>, [?a, ?b, ?c])) == "~r\"foo\"abc"
end
test "tuple" do
assert quoted_to_string(quote do: {1, 2}) == "{1, 2}"
assert quoted_to_string(quote do: {1}) == "{1}"
@@ -0,0 +1,12 @@
defmodule Dialyzer.WithThrowingElse do
def with_throwing_else(map) do
with {:ok, foo} <- Map.fetch(map, :foo),
false <- Enum.empty?(foo) do
foo
else
# several clauses but one is a no_return
:error -> throw(:empty_map)
true -> nil
end
end
end
@@ -26,6 +26,7 @@ defmodule Kernel.DialyzerTest do
:elixir,
:elixir_env,
:elixir_erl_pass,
:maps,
ArgumentError,
Atom,
Code,
@@ -164,6 +165,11 @@ defmodule Kernel.DialyzerTest do
assert_dialyze_no_warnings!(context)
end
test "no warnings on with when multiple else clauses and one is a no_return", context do
copy_beam!(context, Dialyzer.WithThrowingElse)
assert_dialyze_no_warnings!(context)
end
test "no warnings on defmacrop", context do
copy_beam!(context, Dialyzer.Defmacrop)
assert_dialyze_no_warnings!(context)
+10
View File
@@ -190,6 +190,16 @@ defmodule MacroTest do
assert Code.eval_quoted(expanded) == {env.versioned_vars, []}
end
test "env in :match context does not expand" do
env = %{__ENV__ | line: 0, lexical_tracker: self(), context: :match}
expanded = Macro.expand_once(quote(do: __ENV__), env)
assert expanded == quote(do: __ENV__)
expanded = Macro.expand_once(quote(do: __ENV__.file), env)
assert expanded == quote(do: __ENV__.file)
end
defmacro local_macro(), do: raise("ignored")
test "vars" do
@@ -256,6 +256,28 @@ defmodule ExUnit.AssertionsTest do
end
end
test "assert match with __ENV__ in the pattern" do
message =
ExUnit.CaptureIO.capture_io(:stderr, fn ->
assert_raise CompileError, fn ->
Code.eval_string("""
defmodule EnvMatch do
import ExUnit.Assertions
def run do
assert __ENV__ = %{}
end
end
""")
end
end)
assert message =~ "invalid pattern in match, __ENV__ is not allowed in matches"
after
:code.purge(EnvMatch)
:code.delete(EnvMatch)
end
test "assert match?" do
true = assert match?({2, 1}, Value.tuple())
+1 -1
View File
@@ -150,7 +150,7 @@ defmodule IEx.Helpers do
reenable_tasks(config)
force? = Keyword.get(options, :force, false)
args = ["--purge-consolidation-path-if-stale", "--return-errors", consolidation]
args = ["--purge-consolidation-path-if-stale", consolidation, "--return-errors"]
args = if force?, do: ["--force" | args], else: args
{result, _} = Mix.Task.run("compile", args)
+8 -8
View File
@@ -948,13 +948,13 @@ defmodule Logger.TranslatorTest do
test "translates Supervisor progress" do
{:ok, pid} = Supervisor.start_link([], strategy: :one_for_one)
assert capture_log(:info, fn ->
assert capture_log(:debug, fn ->
ref = Process.monitor(pid)
Supervisor.start_child(pid, worker(Task, [__MODULE__, :sleep, [self()]]))
Process.exit(pid, :normal)
receive do: ({:DOWN, ^ref, _, _, _} -> :ok)
end) =~ ~r"""
\[info\] Child Task of Supervisor #PID<\d+\.\d+\.\d+> \(Supervisor\.Default\) started
\[(debug|info)\] Child Task of Supervisor #PID<\d+\.\d+\.\d+> \(Supervisor\.Default\) started
Pid: #PID<\d+\.\d+\.\d+>
Start Call: Task.start_link\(Logger.TranslatorTest, :sleep, \[#PID<\d+\.\d+\.\d+>\]\)
"""
@@ -963,36 +963,36 @@ defmodule Logger.TranslatorTest do
test "translates Supervisor progress with name" do
{:ok, pid} = Supervisor.start_link([], strategy: :one_for_one, name: __MODULE__)
assert capture_log(:info, fn ->
assert capture_log(:debug, fn ->
ref = Process.monitor(pid)
Supervisor.start_child(pid, worker(Task, [__MODULE__, :sleep, [self()]]))
Process.exit(pid, :normal)
receive do: ({:DOWN, ^ref, _, _, _} -> :ok)
end) =~ ~r"""
\[info\] Child Task of Supervisor Logger.TranslatorTest started
\[(debug|info)\] Child Task of Supervisor Logger.TranslatorTest started
"""
{:ok, pid} = Supervisor.start_link([], strategy: :one_for_one, name: {:global, __MODULE__})
assert capture_log(:info, fn ->
assert capture_log(:debug, fn ->
ref = Process.monitor(pid)
Supervisor.start_child(pid, worker(Task, [__MODULE__, :sleep, [self()]]))
Process.exit(pid, :normal)
receive do: ({:DOWN, ^ref, _, _, _} -> :ok)
end) =~ ~r"""
\[info\] Child Task of Supervisor Logger.TranslatorTest started
\[(debug|info)\] Child Task of Supervisor Logger.TranslatorTest started
"""
{:ok, pid} =
Supervisor.start_link([], strategy: :one_for_one, name: {:via, :global, __MODULE__})
assert capture_log(:info, fn ->
assert capture_log(:debug, fn ->
ref = Process.monitor(pid)
Supervisor.start_child(pid, worker(Task, [__MODULE__, :sleep, [self()]]))
Process.exit(pid, :normal)
receive do: ({:DOWN, ^ref, _, _, _} -> :ok)
end) =~ ~r"""
\[info\] Child Task of Supervisor Logger.TranslatorTest started
\[(debug|info)\] Child Task of Supervisor Logger.TranslatorTest started
"""
end
+5 -5
View File
@@ -1077,24 +1077,24 @@ defmodule Mix.Compilers.Elixir do
# We merge stale_modules (which is a map of %{module => true} that the user changed)
# into a map of modules we compiled (which is a map of %{module => record}). This is
# fine because we only care about the keys.
runtime_modules = Map.merge(modules, stale_modules)
changed_modules = Map.merge(modules, stale_modules)
# Now we do a simple pass finding anything that directly depends on the modules that
# changed. We don't need to compute a fixpoint, because now only the directly affected
# matter.
{sources, runtime_modules} =
Enum.reduce(sources, {sources, Map.keys(runtime_modules)}, fn
Enum.reduce(sources, {sources, []}, fn
{source_path, source_entry}, {acc_sources, acc_modules} ->
source(export_references: export_refs, runtime_references: runtime_refs) =
source_entry
if has_any_key?(runtime_modules, export_refs) or
has_any_key?(runtime_modules, runtime_refs) do
if has_any_key?(changed_modules, export_refs) or
has_any_key?(changed_modules, runtime_refs) do
acc_sources =
Map.replace!(acc_sources, source_path, source(source_entry, runtime_warnings: []))
new_modules =
Enum.reject(source(source_entry, :modules), &Map.has_key?(runtime_modules, &1))
Enum.reject(source(source_entry, :modules), &Map.has_key?(changed_modules, &1))
{acc_sources, new_modules ++ acc_modules}
else
+263 -131
View File
@@ -14,16 +14,111 @@ defmodule Mix.Tasks.Xref do
$ mix xref MODE
All available modes are discussed below.
All available modes are discussed below, after a brief
introduction to xref.
This task is automatically re-enabled, so you can print
information multiple times in the same Mix invocation.
## mix xref callers MODULE
## A brief introduction to xref
Prints all callers of the given module. Example:
The goal of `xref` is to analyze the dependencies between modules
and files. It is most commonly used to find problematic areas where
touching one file in a project causes a large subset of the project
to recompile. The most common cause of these problems are the so-called
"compile-connected" files. Those are files you depend on at compile-time
(for example, by invoking its macro or using it in the body of amodule)
which also have their own dependencies.
$ mix xref callers MyMod
Therefore, if your goal is to reduce recompilations, the first step is to run:
$ mix xref graph --format stats --label compile-connected
This command will show general information about the project, but
focus on compile-connected dependencies. In the stats, you will see
the following report:
Top 10 files with most incoming dependencies:
* lib/livebook_web.ex (97)
* lib/livebook/config.ex (3)
* proto/lib/livebook_proto/deployment_group.pb.ex (2)
* lib/livebook_web/plugs/memory_provider.ex (2)
* proto/lib/livebook_proto/user_connected.pb.ex (1)
You can see the first file, "lib/livebook_web.ex", is depended on by 97
other files and, because we are using compile-connected, it also means
that "lib/livebook_web.ex" itself has its own dependencies. We can find
which files depend on "lib/livebook_web.ex" at compile time like this:
$ mix xref graph --sink lib/livebook_web.ex --label compile --only-nodes
And you can find the files lib/livebook_web.ex depends on like this:
$ mix xref graph --source lib/livebook_web.ex --only-nodes
The trouble here is precisely that, if any of the files in the latter
command changes, all of the files in the first command will be recompiled,
because compile time dependencies are transitive.
Having compile time dependencies is a common feature in Elixir projects.
However, the modules you depend on at compile-time must avoid runtime
dependencies within the same project. You can understand all of the
dependencies of a given file by running:
$ mix xref trace lib/livebook_web.ex
The command above will output three types of dependencies, which we
detail next.
### Dependency types
Elixir tracks three types of dependencies between modules: compile,
exports, and runtime. If a module has a compile time dependency on
another module, the caller module has to be recompiled whenever the
callee changes (or any runtime dependency of the callee changes).
Let's see an example:
# lib/a.ex
defmodule A do
@hello B.hello()
def hello, do: @hello
end
# lib/b.ex
defmodule B do
def hello, do: "hello"
def world, do: C.world()
end
# lib/c.ex
defmodule C do
def world, do: "world"
end
If `C.world/0` changes, `B` is marked as stale. `B` does not need to
be recompiled, because it depends on `C` at runtime, but anything that
depends on `B` at compile-time has to recompile, and that includes `A`.
Compile-time dependencies are typically added when using macros or
when invoking functions in the module body (outside of functions).
This type of transitive compile-time dependencies, such as `A`
depending on `C` at compile-time through `B`, are called compile-connected.
Export dependencies are compile time dependencies on the module API,
namely structs and its public definitions. For example, if you import
a module but only use its functions, it is an export dependency. If
you use a struct, it is an export dependency too. Export dependencies
are only recompiled if the module API changes. Note, however, that compile
time dependencies have higher precedence than exports. Therefore if
you import a module and use its macros, it is a compile time dependency.
Runtime dependencies are added whenever you invoke another module
inside a function. Modules with runtime dependencies do not have
to be compiled when the callee changes, unless there is a transitive
compile or an outdated export time dependency between them.
Over the next sections, we will explain what which `mix xref` command
does in detail.
## mix xref trace FILE
@@ -87,14 +182,13 @@ defmodule Mix.Tasks.Xref do
* `--exclude` - path to exclude. Can be repeated to exclude multiple paths.
* `--label` - only shows relationships with the given label.
The labels are "compile", "export" and "runtime". By default,
the `--label` option simply filters the printed graph to show
only relationships with the given label. You can pass `--only-direct`
to trim the graph to only the nodes that have the direct
relationship given by label. There is also a special label
called "compile-connected" that keeps only compile-time files
with at least one transitive dependency. See "Dependency types"
section below.
The labels are "compile", "export" and "runtime". By default, the `--label`
option does not change how the graph is computed, it simply filters the
printed graph to show only relationships with the given label. However,
you can pass `--only-direct` to trim the graph to only the nodes that
have the direct relationship given by label. There is also a special
label called "compile-connected" that keeps only compile-time files with
at least one transitive dependency. See "Dependency types" section below.
* `--group` - provide comma-separated paths to consider as a group. Dependencies
from and into multiple files of the group are considered a single dependency.
@@ -148,8 +242,8 @@ defmodule Mix.Tasks.Xref do
those options with `--label` and `--only-nodes` to get all files that exhibit a certain
property, for example:
# To show all compile-time relationships
$ mix xref graph --label compile
# To show all compile-connected relationships
$ mix xref graph --label compile-connected
# To get the tree that depend on lib/foo.ex at compile time
$ mix xref graph --label compile --sink lib/foo.ex
@@ -163,6 +257,9 @@ defmodule Mix.Tasks.Xref do
# To show general statistics about the graph
$ mix xref graph --format stats
# To show all cycles with at least one compile-time dependency
$ mix xref graph --format cycles --label compile-connected
### Understanding the printed graph
When `mix xref graph` runs, it will print a tree of the following
@@ -204,10 +301,9 @@ defmodule Mix.Tasks.Xref do
The `--label compile` flag removes all non-compile dependencies. However,
this can be misleading because having direct compile time dependencies is
not necessarily an issue. The biggest concern, as mentioned above, are the
transitive compile time dependencies. You can get all compile time
dependencies that cause transitive compile time dependencies by using
`--label compile-connected`:
not necessarily an issue. The biggest concern are the transitive compile
time dependencies. You can get all compile time dependencies that cause
transitive compile time dependencies by using `--label compile-connected`:
$ mix xref graph --label compile-connected
lib/a.ex
@@ -235,56 +331,35 @@ defmodule Mix.Tasks.Xref do
command will list all files from all umbrella children, without
any namespacing.
### Dependency types
### Understanding the printed cycle
Elixir tracks three types of dependencies between modules: compile,
exports, and runtime. If a module has a compile time dependency on
another module, the caller module has to be recompiled whenever the
callee changes (or any runtime dependency of the callee changes).
Let's see an example:
If you run `mix xref graph --format cycle`, Elixir will print cycles
of shape:
# lib/a.ex
defmodule A do
@hello B.hello()
def hello, do: @hello
end
Cycle of length 3:
# lib/b.ex
defmodule B do
def hello, do: "hello"
def world, do: C.world()
end
lib/c.ex
lib/b.ex
lib/a.ex
# lib/c.ex
defmodule C do
def world, do: "world"
end
The cycles are given in order: `c.ex` depends on `b.ex` which depends
on `a.ex` which depends on `c.ex`. In particular, you want to avoid
cycles with compile dependencies in there. You can find those cycles
with:
If `C.world/0` changes, `B` is marked as stale. `B` does not need to
be recompiled, because it depends on `C` at runtime, but anything that
depends on `B` at compile-time has to recompile, and that includes `A`.
$ mix xref graph --format cycles --label compile-connected
Compile-time dependencies are typically added when using macros or
when invoking functions in the module body (outside of functions).
You can list all dependencies in a file by running
`mix xref trace path/to/file.ex`. This type of transitive compile-time
dependencies, such as `A` depending on `C` at compile-time through `B`,
can be found with the "compile-connected" label, as in
`mix xref graph --label compile-connected`.
Which may look like this:
Export dependencies are compile time dependencies on the module API,
namely structs and its public definitions. For example, if you import
a module but only use its functions, it is an export dependency. If
you use a struct, it is an export dependency too. Export dependencies
are only recompiled if the module API changes. Note, however, that compile
time dependencies have higher precedence than exports. Therefore if
you import a module and use its macros, it is a compile time dependency.
Cycle of length 3:
Runtime dependencies are added whenever you invoke another module
inside a function. Modules with runtime dependencies do not have
to be compiled when the callee changes, unless there is a transitive
compile or an outdated export time dependency between them. The option
`--label compile-connected` can be used to find the first case.
lib/c.ex
lib/b.ex (compile)
lib/a.ex
This means `c.ex` depends on `b.ex` at compile time. Any compile dependency
in a cycle is by definition a compile-connected dependency, which must be
generally avoided, as explained earlier in the module documentation.
## Shared options
@@ -846,44 +921,15 @@ defmodule Mix.Tasks.Xref do
true -> file_references
end
# Filter according to non direct label
file_references = filter(file_references, filter)
# If a label is given, remove empty root nodes
file_references =
if opts[:label] do
for {_, [_ | _]} = pair <- file_references, into: %{}, do: pair
else
file_references
end
roots =
if sources do
Enum.map(sources, &{&1, nil})
else
file_references
|> Map.drop(sinks || [])
|> Enum.map(&{elem(&1, 0), nil})
end
callback = fn {file, type} ->
children = if opts[:only_nodes], do: [], else: Map.get(file_references, file, [])
type = type && "(#{type})"
{{file, type}, Enum.sort(children)}
end
{found, count} =
case opts[:format] do
"dot" ->
{roots, callback, count} =
roots_and_callback(file_references, filter, sources, sinks, opts)
path = Keyword.get(opts, :output, "xref_graph.dot")
Mix.Utils.write_dot_graph!(
path,
"xref graph",
Enum.sort(roots),
callback,
opts
)
Mix.Utils.write_dot_graph!(path, "xref graph", Enum.sort(roots), callback, opts)
if path != "-" do
png_path = (path |> Path.rootname() |> Path.basename()) <> ".png"
@@ -899,19 +945,22 @@ defmodule Mix.Tasks.Xref do
|> Mix.shell().info()
end
{:references, count_references(file_references)}
{:references, count}
"stats" ->
print_stats(file_references, opts)
print_stats(file_references, filter, opts)
{:stats, 0}
"cycles" ->
{:cycles, print_cycles(file_references, opts)}
{:cycles, print_cycles(file_references, filter, opts)}
other when other in [nil, "plain", "pretty"] ->
{roots, callback, count} =
roots_and_callback(file_references, filter, sources, sinks, opts)
Mix.Utils.print_tree(Enum.sort(roots), callback, opts)
{:references, count_references(file_references)}
{:references, count}
other ->
Mix.raise("Unknown --format #{other} in mix xref graph")
@@ -920,28 +969,6 @@ defmodule Mix.Tasks.Xref do
check_failure(found, count, opts[:fail_above])
end
defp count_references(file_references) do
Enum.reduce(file_references, 0, fn {_, refs}, total -> total + length(refs) end)
end
defp filter_fn(file_references, :compile_connected),
do: fn {key, type} ->
type == :compile and match?([_ | _], file_references[key] || [])
end
defp filter_fn(_file_references, filter),
do: fn {_key, type} -> type == filter end
defp filter(file_references, :all), do: file_references
defp filter(file_references, filter) do
filter_fn = filter_fn(file_references, filter)
for {key, children} <- file_references,
into: %{},
do: {key, Enum.filter(children, filter_fn)}
end
defp source_tree(file_references, keys) do
keys
|> Enum.reduce({%{}, %{}}, fn key, {acc, seen} ->
@@ -980,7 +1007,59 @@ defmodule Mix.Tasks.Xref do
end)
end
defp print_stats(references, opts) do
defp roots_and_callback(file_references, filter, sources, sinks, opts) do
# Filter according to non direct label
file_references = transitive_filter(file_references, filter)
# If a label is given, remove empty root nodes
file_references =
if opts[:label] do
for {_, [_ | _]} = pair <- file_references, into: %{}, do: pair
else
file_references
end
roots =
if sources do
Enum.map(sources, &{&1, nil})
else
file_references
|> Map.drop(sinks || [])
|> Enum.map(&{elem(&1, 0), nil})
end
callback = fn {file, type} ->
children = if opts[:only_nodes], do: [], else: Map.get(file_references, file, [])
type = type && "(#{type})"
{{file, type}, Enum.sort(children)}
end
{roots, callback, count_references(file_references)}
end
defp count_references(file_references) do
Enum.reduce(file_references, 0, fn {_, refs}, total -> total + length(refs) end)
end
defp transitive_filter_fn(file_references, :compile_connected),
do: fn {key, type} ->
type == :compile and match?([_ | _], file_references[key] || [])
end
defp transitive_filter_fn(_file_references, filter),
do: fn {_key, type} -> type == filter end
defp transitive_filter(file_references, :all), do: file_references
defp transitive_filter(file_references, filter) do
filter_fn = transitive_filter_fn(file_references, filter)
for {key, children} <- file_references,
into: %{},
do: {key, Enum.filter(children, filter_fn)}
end
defp print_stats(references, filter, opts) do
with_digraph(references, fn graph ->
shell = Mix.shell()
@@ -995,11 +1074,11 @@ defmodule Mix.Tasks.Xref do
shell.info("Compile dependencies: #{counters.compile} (edges)")
shell.info("Exports dependencies: #{counters.export} (edges)")
shell.info("Runtime dependencies: #{counters.nil} (edges)")
shell.info("Cycles: #{length(cycles(graph, opts))}")
shell.info("Cycles: #{length(cycles(graph, filter, opts))}")
outgoing =
references
|> Enum.map(fn {file, _} -> {:digraph.out_degree(graph, file), file} end)
|> Enum.map(fn {file, _} -> {out_stats_filter(references, graph, file, filter), file} end)
|> Enum.sort(:desc)
|> Enum.take(10)
@@ -1008,7 +1087,7 @@ defmodule Mix.Tasks.Xref do
incoming =
references
|> Enum.map(fn {file, _} -> {:digraph.in_degree(graph, file), file} end)
|> Enum.map(fn {file, _} -> {in_stats_filter(references, graph, file, filter), file} end)
|> Enum.sort(:desc)
|> Enum.take(10)
@@ -1017,6 +1096,32 @@ defmodule Mix.Tasks.Xref do
end)
end
defp out_stats_filter(_references, graph, file, :all), do: :digraph.out_degree(graph, file)
defp out_stats_filter(references, graph, file, filter) do
filter_fn = transitive_filter_fn(references, filter)
graph
|> :digraph.out_neighbours(file)
|> Enum.count(fn v ->
{_edge, _v1, _v2, label} = :digraph.edge(graph, {file, v})
filter_fn.({file, label})
end)
end
defp in_stats_filter(_references, graph, file, :all), do: :digraph.in_degree(graph, file)
defp in_stats_filter(references, graph, file, filter) do
filter_fn = transitive_filter_fn(references, filter)
graph
|> :digraph.in_neighbours(file)
|> Enum.count(fn v ->
{_edge, _v1, _v2, label} = :digraph.edge(graph, {v, file})
filter_fn.({file, label})
end)
end
defp with_digraph(references, callback) do
graph = :digraph.new()
@@ -1026,7 +1131,7 @@ defmodule Mix.Tasks.Xref do
end
for {file, deps} <- references, {dep, label} <- deps do
:digraph.add_edge(graph, file, dep, label)
:digraph.add_edge(graph, {file, dep}, file, dep, label)
end
callback.(graph)
@@ -1035,7 +1140,7 @@ defmodule Mix.Tasks.Xref do
end
end
defp cycles(graph, opts) do
defp cycles(graph, filter, opts) do
# Vertices order in cyclic_strong_components/1 return is arbitrary and changes between
# OTP versions, sorting is necessary to make the output stable across versions.
cycles =
@@ -1044,21 +1149,47 @@ defmodule Mix.Tasks.Xref do
|> Enum.reduce([], &inner_cycles(graph, Enum.sort(&1), &2))
|> Enum.map(&{length(&1), &1})
if min = opts[:min_cycle_size], do: Enum.filter(cycles, &(elem(&1, 0) > min)), else: cycles
cycles =
if min = opts[:min_cycle_size] do
Enum.filter(cycles, &(elem(&1, 0) > min))
else
cycles
end
# :compile_connected is the same
if cycle_fn = cycle_filter_fn(filter) do
Enum.filter(cycles, fn {_length, cycle} -> Enum.any?(cycle, cycle_fn) end)
else
cycles
end
end
# In cycles, a compile connected is compile
defp cycle_filter_fn(:all), do: nil
defp cycle_filter_fn(:compile_connected), do: cycle_filter_fn(:compile)
defp cycle_filter_fn(filter), do: fn {_node, type} -> type == filter end
defp inner_cycles(_graph, [], acc), do: acc
defp inner_cycles(graph, [v | vertices], acc) do
cycle = :digraph.get_cycle(graph, v)
inner_cycles(graph, vertices -- cycle, [cycle | acc])
inner_cycles(graph, vertices -- cycle, [label_cycle(cycle, graph) | acc])
end
defp print_cycles(references, opts) do
defp label_cycle([from, to | cycle], graph) do
{_edge, _v1, _v2, label} = :digraph.edge(graph, {from, to})
[{to, label} | label_cycle([to | cycle], graph)]
end
defp label_cycle([_from], _graph) do
[]
end
defp print_cycles(references, filter, opts) do
with_digraph(references, fn graph ->
shell = Mix.shell()
case graph |> cycles(opts) |> Enum.sort(:desc) do
case graph |> cycles(filter, opts) |> Enum.sort(:desc) do
[] ->
shell.info("No cycles found")
0
@@ -1069,8 +1200,9 @@ defmodule Mix.Tasks.Xref do
for {length, cycle} <- cycles do
shell.info("Cycle of length #{length}:\n")
for node <- cycle do
shell.info(" " <> node)
for {node, type} <- cycle do
type = if type, do: " (#{type})", else: ""
shell.info(" " <> node <> type)
end
shell.info("")
+73 -2
View File
@@ -439,14 +439,85 @@ defmodule Mix.Tasks.XrefTest do
""")
end
test "stats with compile label" do
assert_graph(["--format", "stats", "--label", "compile"], """
Tracked files: 5 (nodes)
Compile dependencies: 3 (edges)
Exports dependencies: 0 (edges)
Runtime dependencies: 3 (edges)
Cycles: 1
Top 5 files with most outgoing dependencies:
* lib/c.ex (1)
* lib/b.ex (1)
* lib/a.ex (1)
* lib/e.ex (0)
* lib/d.ex (0)
Top 5 files with most incoming dependencies:
* lib/e.ex (1)
* lib/d.ex (1)
* lib/b.ex (1)
* lib/c.ex (0)
* lib/a.ex (0)
""")
end
test "stats with compile-connected label" do
assert_graph(["--format", "stats", "--label", "compile-connected"], """
Tracked files: 5 (nodes)
Compile dependencies: 3 (edges)
Exports dependencies: 0 (edges)
Runtime dependencies: 3 (edges)
Cycles: 1
Top 5 files with most outgoing dependencies:
* lib/c.ex (1)
* lib/b.ex (1)
* lib/a.ex (1)
* lib/e.ex (0)
* lib/d.ex (0)
Top 5 files with most incoming dependencies:
* lib/d.ex (1)
* lib/b.ex (1)
* lib/e.ex (0)
* lib/c.ex (0)
* lib/a.ex (0)
""")
end
test "cycles" do
assert_graph(["--format", "cycles"], """
1 cycles found. Showing them in decreasing size:
Cycle of length 3:
Cycle of length 2:
lib/b.ex (compile)
lib/a.ex
lib/b.ex
""")
end
test "cycles with compile label require at least one of such type" do
assert_graph(["--format", "cycles", "--label", "compile"], """
1 cycles found. Showing them in decreasing size:
Cycle of length 2:
lib/b.ex (compile)
lib/a.ex
""")
end
test "cycles with compile-connected label is the same as compile" do
assert_graph(["--format", "cycles", "--label", "compile-connected"], """
1 cycles found. Showing them in decreasing size:
Cycle of length 2:
lib/b.ex (compile)
lib/a.ex
""")