Skip to content

Commit 37b6f0b

Browse files
authored
mix xref graph - add json support and related changes (#14327)
This patch adds support for output the xref graph as a two level Map of Maps which can be trivially loaded by anything that can process JSON data. The top level Map contains keys which specify source files whose values are maps contain sink files as keys, and dependency type data as values. Source files with no dependencies have empty maps as values. For example a if "lib/foo.ex" has a compile time dependency on "lib/bar.ex" which had no dependencies at all, then the output would look like this: { "lib/foo.ex": { "lib/bar.ex": "compile" }, "lib/bar.ex": {}, } At the same time it adds support for renaming existing xref_graph.dot and xref_graph.json files to have a .bak extension instead of overwriting them. This patch also includes logic to fix some misleading verbiage output by the 'dot' format when the output file is not in the current working directory. This change is included with the JSON changes because I refactored the logic for writing to a file so that both the 'json' and 'dot' formats would use the same code. Closes #14324.
1 parent 5998c0e commit 37b6f0b

File tree

4 files changed

+280
-20
lines changed

4 files changed

+280
-20
lines changed

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

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -252,16 +252,31 @@ defmodule Mix.Tasks.Xref do
252252
253253
* `cycles` - prints all strongly connected cycles in the graph;
254254
255-
* `dot` - produces a DOT graph description in `xref_graph.dot` in the
256-
current directory. Warning: this will override any previously generated file
255+
* `dot` - produces a DOT graph description, by default written to `xref_graph.dot`
256+
in the current directory. See the documentation for the `--output` option to
257+
learn how to control where the file is written and other related details.
257258
258-
* `--output` *(since v1.15.0)* - can be set to one of
259+
* `json` *(since v1.19.0)* - produces a JSON file, by default written to
260+
`xref_graph.json` in the current directory. See the documentation for the
261+
`--output` option to learn how to control where the file is written and other
262+
related details.
263+
264+
The JSON format is always a two level map of maps. The top level keys
265+
specify source files, with their values containing maps whose keys specify
266+
sink files and whose values specify the type of relationship, which will
267+
be one of `compile`, `export` or `runtime`. Files which have no dependencies
268+
will be present in the top level map, and will have empty maps for values.
269+
270+
* `--output` *(since v1.15.0)* - can be used to override the location of
271+
the files created by the `dot` and `json` formats. It can be set to
259272
260273
* `-` - prints the output to standard output;
261274
262275
* a path - writes the output graph to the given path
263276
264-
Defaults to `xref_graph.dot` in the current directory.
277+
If the output file already exists then it will be renamed in place
278+
to have a `.bak` suffix, possibly overwriting any existing `.bak` file.
279+
If this rename fails a fatal exception will be thrown.
265280
266281
The `--source` and `--sink` options are particularly useful when trying to understand
267282
how the modules in a particular file interact with the whole system. You can combine
@@ -949,17 +964,22 @@ defmodule Mix.Tasks.Xref do
949964
{roots, callback, count} =
950965
roots_and_callback(file_references, filter, sources, sinks, opts)
951966

952-
path = Keyword.get(opts, :output, "xref_graph.dot")
953-
954-
Mix.Utils.write_dot_graph!(path, "xref graph", Enum.sort(roots), callback, opts)
967+
file_spec =
968+
Mix.Utils.write_dot_graph!(
969+
"xref_graph.dot",
970+
"xref graph",
971+
Enum.sort(roots),
972+
callback,
973+
opts
974+
)
955975

956-
if path != "-" do
957-
png_path = (path |> Path.rootname() |> Path.basename()) <> ".png"
976+
if file_spec != "-" do
977+
png_file_spec = (file_spec |> Path.rootname() |> Path.basename()) <> ".png"
958978

959979
"""
960-
Generated #{inspect(path)} in the current directory. To generate a PNG:
980+
Generated "#{Path.relative_to_cwd(file_spec)}". To generate a PNG:
961981
962-
dot -Tpng #{inspect(path)} -o #{inspect(png_path)}
982+
dot -Tpng #{inspect(file_spec)} -o #{inspect(png_file_spec)}
963983
964984
For more options see http://www.graphviz.org/.
965985
"""
@@ -976,6 +996,19 @@ defmodule Mix.Tasks.Xref do
976996
"cycles" ->
977997
{:cycles, print_cycles(file_references, filter, opts)}
978998

999+
"json" ->
1000+
{roots, callback, count} =
1001+
roots_and_callback(file_references, filter, sources, sinks, opts)
1002+
1003+
file_spec =
1004+
Mix.Utils.write_json_tree!("xref_graph.json", Enum.sort(roots), callback, opts)
1005+
1006+
if file_spec != "-" do
1007+
Mix.shell().info("Generated \"#{file_spec}\".")
1008+
end
1009+
1010+
{:references, count}
1011+
9791012
other when other in [nil, "plain", "pretty"] ->
9801013
{roots, callback, count} =
9811014
roots_and_callback(file_references, filter, sources, sinks, opts)

lib/mix/lib/mix/utils.ex

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
# SPDX-FileCopyrightText: 2021 The Elixir Team
33
# SPDX-FileCopyrightText: 2012 Plataformatec
44

5+
# NOTE: As this is a utils file it should not contain hard coded files
6+
# everything should be parameterized.
7+
58
defmodule Mix.Utils do
69
@moduledoc false
710

@@ -261,6 +264,75 @@ defmodule Mix.Utils do
261264
|> Enum.uniq()
262265
end
263266

267+
@doc """
268+
Handles writing the contents to either STDOUT or to a file, as specified
269+
by the :output keyword in opts, defaulting to the provided default_file_spec.
270+
271+
If the resolved file specification is "-" then the contents is written to STDOUT,
272+
otherwise if the file already exists it is renamed with a ".bak" suffix before
273+
the contents is written. The underlying IO operations will throw an exception
274+
if there is an error.
275+
276+
Returns the name of the file written to, or "-" if the output was to STDOUT.
277+
This function is made public mostly for testing.
278+
"""
279+
@spec write_according_to_opts!(Path.t(), iodata(), keyword) :: Path.t()
280+
def write_according_to_opts!(default_file_spec, contents, opts) do
281+
file_spec = Keyword.get(opts, :output, default_file_spec)
282+
283+
if file_spec == "-" do
284+
IO.write(contents)
285+
else
286+
if File.exists?(file_spec) do
287+
new_file_spec = "#{file_spec}.bak"
288+
File.rename!(file_spec, new_file_spec)
289+
end
290+
291+
File.write!(file_spec, contents)
292+
end
293+
294+
# return the file_spec just in case the caller has a use for it.
295+
file_spec
296+
end
297+
298+
@doc """
299+
Outputs the given tree according to the callback as a two level
300+
map of maps in JSON format.
301+
302+
The callback will be invoked for each node and it
303+
must return a `{printed, children}` tuple.
304+
305+
If the `:output` option is `-` then prints to standard output,
306+
see write_according_to_opts!/3 for details.
307+
"""
308+
@spec write_json_tree!(Path.t(), [node], (node -> {formatted_node, [node]}), keyword) :: :ok
309+
when node: term()
310+
def write_json_tree!(default_file_spec, nodes, callback, opts \\ []) do
311+
src_map = build_json_tree(_src_map = %{}, nodes, callback)
312+
write_according_to_opts!(default_file_spec, JSON.encode_to_iodata!(src_map), opts)
313+
end
314+
315+
defp build_json_tree(src_map, [], _callback), do: src_map
316+
317+
defp build_json_tree(src_map, nodes, callback) do
318+
Enum.reduce(nodes, src_map, fn node, src_map ->
319+
{{name, _}, children} = callback.(node)
320+
321+
if Map.has_key?(src_map, name) do
322+
src_map
323+
else
324+
sink_map =
325+
Enum.reduce(children, %{}, fn {name, info}, sink_map ->
326+
info = if info == nil, do: "runtime", else: Atom.to_string(info)
327+
Map.put(sink_map, name, info)
328+
end)
329+
330+
Map.put(src_map, name, sink_map)
331+
|> build_json_tree(children, callback)
332+
end
333+
end)
334+
end
335+
264336
@type formatted_node :: {name :: String.Chars.t(), edge_info :: String.Chars.t()}
265337

266338
@doc """
@@ -333,25 +405,21 @@ defmodule Mix.Utils do
333405
The callback will be invoked for each node and it
334406
must return a `{printed, children}` tuple.
335407
336-
If `path` is `-`, prints the output to standard output.
408+
If the `:output` option is `-` then prints to standard output,
409+
see write_according_to_opts!/3 for details.
337410
"""
338411
@spec write_dot_graph!(
339412
Path.t(),
340413
String.t(),
341414
[node],
342415
(node -> {formatted_node, [node]}),
343416
keyword
344-
) :: :ok
417+
) :: Path.t()
345418
when node: term()
346-
def write_dot_graph!(path, title, nodes, callback, _opts \\ []) do
419+
def write_dot_graph!(default_file_spec, title, nodes, callback, opts \\ []) do
347420
{dot, _} = build_dot_graph(make_ref(), nodes, MapSet.new(), callback)
348421
contents = ["digraph ", quoted(title), " {\n", dot, "}\n"]
349-
350-
if path == "-" do
351-
IO.write(contents)
352-
else
353-
File.write!(path, contents)
354-
end
422+
write_according_to_opts!(default_file_spec, contents, opts)
355423
end
356424

357425
defp build_dot_graph(_parent, [], seen, _callback), do: {[], seen}

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

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,14 @@ defmodule Mix.Tasks.XrefTest do
863863
"lib/b.ex"
864864
}
865865
"""
866+
867+
assert Mix.Task.run("xref", ["graph", "--format", "json", "--output", "xref_graph.json"]) ==
868+
:ok
869+
870+
assert File.read!("xref_graph.json") ===
871+
String.trim_trailing("""
872+
{"lib/a.ex":{"lib/b.ex":"compile"},"lib/b.ex":{}}
873+
""")
866874
end)
867875
end
868876

@@ -891,6 +899,14 @@ defmodule Mix.Tasks.XrefTest do
891899
"lib/b.ex"
892900
}
893901
"""
902+
903+
assert Mix.Task.run("xref", ["graph", "--format", "json", "--output", "xref_graph.json"]) ==
904+
:ok
905+
906+
assert File.read!("xref_graph.json") ===
907+
String.trim_trailing("""
908+
{"lib/a.ex":{"lib/b.ex":"export"},"lib/b.ex":{}}
909+
""")
894910
end)
895911
end
896912

@@ -933,6 +949,16 @@ defmodule Mix.Tasks.XrefTest do
933949
end
934950
""")
935951

952+
output =
953+
capture_io(fn ->
954+
assert Mix.Task.run("xref", ["graph", "--format", "json", "--output", "-"]) == :ok
955+
end)
956+
957+
assert output ===
958+
String.trim_trailing("""
959+
{"lib/a.ex":{},"lib/b.ex":{}}
960+
""")
961+
936962
output =
937963
capture_io(fn ->
938964
assert Mix.Task.run("xref", ["graph", "--format", "dot", "--output", "-"]) == :ok
@@ -984,6 +1010,14 @@ defmodule Mix.Tasks.XrefTest do
9841010
"lib/b.ex"
9851011
}
9861012
"""
1013+
1014+
assert Mix.Task.run("xref", ["graph", "--format", "json"]) ==
1015+
:ok
1016+
1017+
assert File.read!("xref_graph.json") ===
1018+
String.trim_trailing("""
1019+
{"lib/a.ex":{"lib/b.ex":"compile"},"lib/b.ex":{"lib/a.ex":"compile"}}
1020+
""")
9871021
end)
9881022
end
9891023

@@ -1127,6 +1161,44 @@ defmodule Mix.Tasks.XrefTest do
11271161
""")
11281162
end
11291163

1164+
test "dot with cycle" do
1165+
assert_graph_dot(
1166+
~w[],
1167+
"""
1168+
digraph "xref graph" {
1169+
"lib/a.ex"
1170+
"lib/a.ex" -> "lib/b.ex" [label="(compile)"]
1171+
"lib/b.ex" -> "lib/a.ex"
1172+
"lib/b.ex" -> "lib/c.ex"
1173+
"lib/c.ex" -> "lib/d.ex" [label="(compile)"]
1174+
"lib/d.ex" -> "lib/e.ex"
1175+
"lib/b.ex" -> "lib/e.ex" [label="(compile)"]
1176+
"lib/b.ex"
1177+
"lib/c.ex"
1178+
"lib/d.ex"
1179+
"lib/e.ex"
1180+
}
1181+
"""
1182+
)
1183+
end
1184+
1185+
test "json with cycle" do
1186+
assert_graph_json(
1187+
~w[],
1188+
"""
1189+
{ "lib/a.ex": { "lib/b.ex": "compile" },
1190+
"lib/b.ex": { "lib/a.ex": "runtime",
1191+
"lib/c.ex": "runtime",
1192+
"lib/e.ex": "compile" },
1193+
"lib/c.ex": { "lib/d.ex": "compile" },
1194+
"lib/d.ex": { "lib/e.ex": "runtime" },
1195+
"lib/e.ex": { } }
1196+
""",
1197+
# make it easier to read the expected output
1198+
strip_ws: true
1199+
)
1200+
end
1201+
11301202
@default_files %{
11311203
"lib/a.ex" => """
11321204
defmodule A do
@@ -1160,6 +1232,43 @@ defmodule Mix.Tasks.XrefTest do
11601232
"""
11611233
}
11621234

1235+
defp assert_graph_json(args, expected, opts) do
1236+
assert_graph_io("json", args, expected, opts)
1237+
end
1238+
1239+
defp assert_graph_dot(args, expected, opts \\ []) do
1240+
assert_graph_io("dot", args, expected, opts)
1241+
end
1242+
1243+
# note that we trim_trailing on expected and output
1244+
# we also support :strip_ws in opts, which removes all
1245+
# whitespace from expected (but not output!) before performing
1246+
# the test.
1247+
defp assert_graph_io(format, args, expected, opts) do
1248+
in_fixture("no_mixfile", fn ->
1249+
Enum.each(opts[:files] || @default_files, fn {path, content} ->
1250+
File.write!(path, content)
1251+
end)
1252+
1253+
output =
1254+
String.trim_trailing(
1255+
capture_io(fn ->
1256+
assert Mix.Task.run(
1257+
"xref",
1258+
args ++ ["graph", "--format", format, "--output", "-"]
1259+
) == :ok
1260+
end)
1261+
)
1262+
1263+
expected =
1264+
if opts[:strip_ws],
1265+
do: Regex.replace(~r/\s+/, expected, "", global: true),
1266+
else: String.trim_trailing(expected)
1267+
1268+
assert output === expected
1269+
end)
1270+
end
1271+
11631272
defp assert_graph(args \\ [], expected, opts \\ []) do
11641273
in_fixture("no_mixfile", fn ->
11651274
nb_files =

0 commit comments

Comments
 (0)