Skip to content

Commit c2c331d

Browse files
committed
Emit a warning if source is part of a cycle
1 parent b42dbd9 commit c2c331d

File tree

2 files changed

+111
-42
lines changed

2 files changed

+111
-42
lines changed

lib/mix/lib/mix/tasks/xref.ex

+76-24
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,44 @@ defmodule Mix.Tasks.Xref do
3434
(for example, by invoking its macro or using it in the body of a module)
3535
which also have their own dependencies.
3636
37-
Therefore, if your goal is to reduce recompilations, the first step is to run:
37+
The most harmful form of compile-connected dependencies are the ones
38+
that are also in a cycle. Imagine you have files `lib/a.ex`, `lib/b.ex`,
39+
and `lib/c.ex` with the following dependencies:
40+
41+
lib/a.ex
42+
└── lib/b.ex (compile)
43+
└── lib/c.ex
44+
└── lib/a.ex
45+
46+
Because you have a compile-time dependency, any of the files `lib/a.ex`,
47+
`lib/b.ex`, and `lib/c.ex` depend on will cause the whole cycle to
48+
recompile. Therefore, your first priority to reduce compile times is
49+
to remove such cycles. You can spot them by running:
3850
3951
$ mix xref graph --format stats --label compile-connected
4052
41-
This command will show general information about the project, but
42-
focus on compile-connected dependencies. In the stats, you will see
43-
the following report:
53+
Whenever you find a compile-time dependency, such as `lib/a.ex` pointing
54+
to `lib/b.ex`, there are two ways to remove them:
55+
56+
1. Run `mix xref trace lib/a.ex` to understand where and how `lib/a.ex`
57+
depends on `lib/b.ex` at compile time and address it
58+
59+
2. Or run `mix xref trace lib/b.ex` and make sure it does not depend on
60+
any other module in your project because a compile dependency makes
61+
those runtime dependencies also compile time by transitivity
62+
63+
We outline all options for `mix xref trace` and the types of dependencies
64+
over the following sections.
65+
66+
If you don't have compile cycles in your project, that's a good beginning,
67+
but you want to avoid any compile-connected dependencies in general, as they
68+
may become cycles in the future. To verify the general health of your project,
69+
you may run:
70+
71+
$ mix xref graph --format stats --label compile-connected
72+
73+
This command will show general information about the project, but focus on
74+
compile-connected dependencies. In the stats, you will see the following report:
4475
4576
Top 10 files with most incoming dependencies:
4677
* lib/livebook_web.ex (97)
@@ -62,17 +93,9 @@ defmodule Mix.Tasks.Xref do
6293
6394
The trouble here is precisely that, if any of the files in the latter
6495
command changes, all of the files in the first command will be recompiled,
65-
because compile time dependencies are transitive.
66-
67-
Having compile time dependencies is a common feature in Elixir projects.
68-
However, the modules you depend on at compile-time must avoid dependencies
69-
to modules within the same project. You can understand all of the
70-
dependencies of a given file by running:
71-
72-
$ mix xref trace lib/livebook_web.ex
73-
74-
The command above will output three types of dependencies, which we
75-
detail next.
96+
because compile time dependencies are transitive. As we did with cycles,
97+
you can use `mix xref trace` to understand why and how these dependencies
98+
exist.
7699
77100
### Dependency types
78101
@@ -911,20 +934,20 @@ defmodule Mix.Tasks.Xref do
911934
if files == [], do: nil, else: files
912935
end
913936

914-
defp write_graph(file_references, filter, opts) do
915-
{file_references, aliases} = merge_groups(file_references, Keyword.get_values(opts, :group))
937+
defp write_graph(all_references, filter, opts) do
938+
{all_references, aliases} = merge_groups(all_references, Keyword.get_values(opts, :group))
916939

917-
file_references =
918-
exclude(file_references, get_files(:exclude, opts, file_references, aliases))
940+
all_references =
941+
exclude(all_references, get_files(:exclude, opts, all_references, aliases))
919942

920-
sources = get_files(:source, opts, file_references, aliases)
921-
sinks = get_files(:sink, opts, file_references, aliases)
943+
sources = get_files(:source, opts, all_references, aliases)
944+
sinks = get_files(:sink, opts, all_references, aliases)
922945

923946
file_references =
924947
cond do
925-
sinks -> sink_tree(file_references, sinks)
926-
sources -> source_tree(file_references, sources)
927-
true -> file_references
948+
sinks -> sink_tree(all_references, sinks)
949+
sources -> source_tree(all_references, sources)
950+
true -> all_references
928951
end
929952

930953
{found, count} =
@@ -966,6 +989,12 @@ defmodule Mix.Tasks.Xref do
966989

967990
Mix.Utils.print_tree(Enum.sort(roots), callback, opts)
968991

992+
if sources do
993+
# We compute the tree again in case sinks are also given
994+
file_references = source_tree(all_references, sources)
995+
print_sources_cycles(file_references, sources, opts)
996+
end
997+
969998
{:references, count}
970999

9711000
other ->
@@ -1219,6 +1248,29 @@ defmodule Mix.Tasks.Xref do
12191248
end)
12201249
end
12211250

1251+
defp print_sources_cycles(references, sources, opts) do
1252+
with_digraph(references, fn graph ->
1253+
shell = Mix.shell()
1254+
1255+
graph
1256+
|> cycles(:compile, opts)
1257+
|> Enum.sort(:desc)
1258+
|> Enum.each(fn {length, cycle} ->
1259+
if source = Enum.find(sources, &List.keymember?(cycle, &1, 0)) do
1260+
shell.info("""
1261+
1262+
WARNING: Source #{source} is part of a cycle of #{length} nodes \
1263+
and this cycle has a compile dependency. Therefore source and the \
1264+
whole cycle will recompile whenever any of the files they depend \
1265+
on change. Run "mix xref graph --format stats --label compile-connected" \
1266+
to print compilation cycles and "mix help xref" for information on \
1267+
removing them\
1268+
""")
1269+
end
1270+
end)
1271+
end)
1272+
end
1273+
12221274
## Helpers
12231275

12241276
defp apps(opts) do

lib/mix/test/mix/tasks/xref_test.exs

+35-18
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,14 @@ defmodule Mix.Tasks.XrefTest do
205205
end)
206206
end
207207

208-
defp assert_callers(opts \\ [], module, files, expected) do
208+
defp assert_callers(args \\ [], module, files, expected) do
209209
in_fixture("no_mixfile", fn ->
210210
for {file, contents} <- files do
211211
File.write!(file, contents)
212212
end
213213

214214
capture_io(:stderr, fn ->
215-
assert Mix.Task.run("xref", opts ++ ["callers", module]) == :ok
215+
assert Mix.Task.run("xref", args ++ ["callers", module]) == :ok
216216
end)
217217

218218
assert ^expected = receive_until_no_messages([])
@@ -392,14 +392,14 @@ defmodule Mix.Tasks.XrefTest do
392392
end
393393
end
394394

395-
defp assert_trace(opts \\ [], file, files, expected) do
395+
defp assert_trace(args \\ [], file, files, expected) do
396396
in_fixture("no_mixfile", fn ->
397397
for {file, contents} <- files do
398398
File.write!(file, contents)
399399
end
400400

401401
capture_io(:stderr, fn ->
402-
assert Mix.Task.run("xref", opts ++ ["trace", file]) == :ok
402+
assert Mix.Task.run("xref", args ++ ["trace", file]) == :ok
403403
end)
404404

405405
assert receive_until_no_messages([]) == expected
@@ -715,16 +715,25 @@ defmodule Mix.Tasks.XrefTest do
715715
end
716716

717717
test "sources" do
718-
assert_graph(~w[--source lib/a.ex --source lib/c.ex], """
719-
lib/a.ex
720-
`-- lib/b.ex (compile)
721-
|-- lib/a.ex
722-
|-- lib/c.ex
723-
`-- lib/e.ex (compile)
724-
lib/c.ex
725-
`-- lib/d.ex (compile)
726-
`-- lib/e.ex
727-
""")
718+
assert_graph(
719+
~w[--source lib/a.ex --source lib/c.ex],
720+
"""
721+
lib/a.ex
722+
`-- lib/b.ex (compile)
723+
|-- lib/a.ex
724+
|-- lib/c.ex
725+
`-- lib/e.ex (compile)
726+
lib/c.ex
727+
`-- lib/d.ex (compile)
728+
`-- lib/e.ex
729+
730+
WARNING: Source lib/a.ex is part of a cycle of 2 nodes and this cycle has a compile \
731+
dependency. Therefore source and the whole cycle will recompile whenever any of the \
732+
files they depend on change. Run "mix xref graph --format stats --label compile-connected" \
733+
to print compilation cycles and "mix help xref" for information on removing them
734+
""",
735+
warnings: true
736+
)
728737
end
729738

730739
test "source with compile label" do
@@ -1151,20 +1160,28 @@ defmodule Mix.Tasks.XrefTest do
11511160
"""
11521161
}
11531162

1154-
defp assert_graph(opts \\ [], expected, params \\ []) do
1163+
defp assert_graph(args \\ [], expected, opts \\ []) do
11551164
in_fixture("no_mixfile", fn ->
11561165
nb_files =
1157-
Enum.count(params[:files] || @default_files, fn {path, content} ->
1166+
Enum.count(opts[:files] || @default_files, fn {path, content} ->
11581167
File.write!(path, content)
11591168
end)
11601169

1161-
assert Mix.Task.run("xref", opts ++ ["graph"]) == :ok
1170+
assert Mix.Task.run("xref", args ++ ["graph"]) == :ok
11621171
first_line = "Compiling #{nb_files} files (.ex)"
11631172

11641173
assert [
1165-
^first_line | ["Generated sample app" | result]
1174+
^first_line,
1175+
"Generated sample app" | result
11661176
] = receive_until_no_messages([]) |> String.split("\n")
11671177

1178+
result =
1179+
if Keyword.get(opts, :warnings, false) do
1180+
result
1181+
else
1182+
Enum.take_while(result, &(not String.starts_with?(&1, "WARNING: ")))
1183+
end
1184+
11681185
assert result |> Enum.join("\n") |> normalize_graph_output() == expected
11691186
end)
11701187
end

0 commit comments

Comments
 (0)