Skip to content

Commit a977afc

Browse files
ericentinJosé Valim
authored andcommitted
mix xref graph (#4811)
Signed-off-by: José Valim <[email protected]>
1 parent 8ed30ad commit a977afc

File tree

7 files changed

+353
-44
lines changed

7 files changed

+353
-44
lines changed

lib/mix/lib/mix/tasks/app.tree.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ defmodule Mix.Tasks.App.Tree do
5656

5757
if opts[:format] == "dot" do
5858
Mix.Utils.write_dot_graph!("app_tree.dot", "application tree",
59-
{:normal, app}, callback, opts)
59+
[{:normal, app}], callback, opts)
6060
"""
6161
Generated "app_tree.dot" in the current directory. To generate a PNG:
6262
@@ -67,7 +67,7 @@ defmodule Mix.Tasks.App.Tree do
6767
|> String.trim_trailing
6868
|> Mix.shell.info
6969
else
70-
Mix.Utils.print_tree({:normal, app}, callback, opts)
70+
Mix.Utils.print_tree([{:normal, app}], callback, opts)
7171
end
7272
end
7373

lib/mix/lib/mix/tasks/deps.tree.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ defmodule Mix.Tasks.Deps.Tree do
5151

5252
if opts[:format] == "dot" do
5353
callback = callback(&format_dot/1, deps, opts)
54-
Mix.Utils.write_dot_graph!("deps_tree.dot", "dependency tree", root, callback, opts)
54+
Mix.Utils.write_dot_graph!("deps_tree.dot", "dependency tree", [root], callback, opts)
5555
"""
5656
Generated "deps_tree.dot" in the current directory. To generate a PNG:
5757
@@ -63,7 +63,7 @@ defmodule Mix.Tasks.Deps.Tree do
6363
|> Mix.shell.info
6464
else
6565
callback = callback(&format_tree/1, deps, opts)
66-
Mix.Utils.print_tree(root, callback, opts)
66+
Mix.Utils.print_tree([root], callback, opts)
6767
end
6868
end
6969

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

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ defmodule Mix.Tasks.Xref do
22
use Mix.Task
33

44
alias Mix.Tasks.Compile.Elixir, as: E
5-
import Mix.Compilers.Elixir, only: [read_manifest: 2, source: 1, source: 2]
5+
import Mix.Compilers.Elixir, only: [read_manifest: 2, source: 1, source: 2, module: 1]
66

77
@shortdoc "Performs cross reference checks"
88
@recursive true
@@ -12,18 +12,45 @@ defmodule Mix.Tasks.Xref do
1212
1313
## Xref modes
1414
15-
The following commands are available:
15+
The following commands and options are available:
1616
1717
* `warnings` - prints warnings for violated cross reference checks
18+
1819
* `unreachable` - prints all unreachable "file:line: module.function/arity" entries
20+
1921
* `callers CALLEE` - prints all references of `CALLEE`, which can be one of: `Module`,
2022
`Module.function`, or `Module.function/arity`
2123
22-
## Command line options
24+
* `graph` - prints the file reference graph. By default, an edge from `A` to `B` indicates
25+
that `A` depends on `B`
26+
27+
* `--exclude` - paths to exclude
28+
29+
* `--source` - display only files for which there is a path from the
30+
given source file
31+
32+
* `--sink` - display only files for which there is a path to the
33+
given sink file.
34+
35+
* `--format` - can be set to one of:
36+
37+
* `pretty` - use Unicode codepoints for formatting the graph. This is the default except on
38+
Windows
39+
40+
* `plain` - do not use Unicode codepoints for formatting the graph. This is the default on
41+
Windows
42+
43+
* `dot` - produces a DOT graph description in `xref_graph.dot` in the
44+
current directory. Warning: this will override any previously generated file
45+
46+
## Options for all commands
2347
2448
* `--no-compile` - do not compile even if files require compilation
49+
2550
* `--no-deps-check` - do not check dependencies
51+
2652
* `--no-archives-check` - do not check archives
53+
2754
* `--no-elixir-version-check` - do not check the Elixir version from mix.exs
2855
2956
## Configuration
@@ -36,7 +63,8 @@ defmodule Mix.Tasks.Xref do
3663
"""
3764

3865
@switches [compile: :boolean, deps_check: :boolean, archives_check: :boolean,
39-
elixir_version_check: :boolean]
66+
elixir_version_check: :boolean, exclude: :keep, format: :string,
67+
source: :string, sink: :string]
4068

4169
@doc """
4270
Runs this task.
@@ -57,8 +85,10 @@ defmodule Mix.Tasks.Xref do
5785
unreachable()
5886
["callers", callee] ->
5987
callers(callee)
88+
["graph"] ->
89+
graph(opts)
6090
_ ->
61-
Mix.raise "xref expects one of the following commands: warnings, unreachable, callers CALLEE"
91+
Mix.raise "xref doesn't support this command, see mix help xref for more information"
6292
end
6393
end
6494

@@ -88,6 +118,12 @@ defmodule Mix.Tasks.Xref do
88118
:ok
89119
end
90120

121+
defp graph(opts) do
122+
write_graph(file_references(), excluded(opts), opts)
123+
124+
:ok
125+
end
126+
91127
## Unreachable
92128

93129
defp unreachable(pair_fun) do
@@ -283,6 +319,124 @@ defmodule Mix.Tasks.Xref do
283319
Mix.raise message
284320
end
285321

322+
## Graph helpers
323+
324+
defp excluded(opts) do
325+
Keyword.get_values(opts, :exclude)
326+
|> Enum.flat_map(&[{&1, nil}, {&1, "(compile)"}, {&1, "(runtime)"}])
327+
end
328+
329+
defp file_references() do
330+
module_sources =
331+
for manifest <- E.manifests(),
332+
manifest_data = read_manifest(manifest, ""),
333+
module(module: module, source: source) <- manifest_data,
334+
source = Enum.find(manifest_data, &match?(source(source: ^source), &1)),
335+
do: {module, source},
336+
into: %{}
337+
338+
all_modules = MapSet.new(module_sources, &elem(&1, 0))
339+
340+
Map.new module_sources, fn {module, source} ->
341+
source(runtime_references: runtime, compile_references: compile, source: file) = source
342+
compile_references =
343+
compile
344+
|> MapSet.new()
345+
|> MapSet.delete(module)
346+
|> MapSet.intersection(all_modules)
347+
|> Enum.filter(&module_sources[&1] != source)
348+
|> Enum.map(&{source(module_sources[&1], :source), "(compile)"})
349+
350+
runtime_references =
351+
runtime
352+
|> MapSet.new()
353+
|> MapSet.delete(module)
354+
|> MapSet.intersection(all_modules)
355+
|> Enum.filter(&module_sources[&1] != source)
356+
|> Enum.map(&{source(module_sources[&1], :source), nil})
357+
358+
{file, compile_references ++ runtime_references}
359+
end
360+
end
361+
362+
defp write_graph(file_references, excluded, opts) do
363+
{root, file_references} =
364+
case {opts[:source], opts[:sink]} do
365+
{nil, nil} ->
366+
{Enum.map(file_references, &{elem(&1, 0), nil}) -- excluded, file_references}
367+
368+
{source, nil} ->
369+
if file_references[source] do
370+
{[{source, nil}], file_references}
371+
else
372+
Mix.raise "Source could not be found: #{source}"
373+
end
374+
375+
{nil, sink} ->
376+
if file_references[sink] do
377+
file_references = filter_for_sink(file_references, sink)
378+
roots =
379+
file_references
380+
|> Map.delete(sink)
381+
|> Enum.map(&{elem(&1, 0), nil})
382+
{roots -- excluded, file_references}
383+
else
384+
Mix.raise "Sink could not be found: #{sink}"
385+
end
386+
387+
{_, _} ->
388+
Mix.raise "mix xref graph expects only one of --source and --sink"
389+
end
390+
391+
callback =
392+
fn {file, type} ->
393+
children = Map.get(file_references, file, [])
394+
{{file, type}, children -- excluded}
395+
end
396+
397+
if opts[:format] == "dot" do
398+
Mix.Utils.write_dot_graph!("xref_graph.dot", "xref graph",
399+
root, callback, opts)
400+
"""
401+
Generated "xref_graph.dot" in the current directory. To generate a PNG:
402+
403+
dot -Tpng xref_graph.dot -o xref_graph.png
404+
405+
For more options see http://www.graphviz.org/.
406+
"""
407+
|> String.trim_trailing()
408+
|> Mix.shell.info()
409+
else
410+
Mix.Utils.print_tree(root, callback, opts)
411+
end
412+
end
413+
414+
defp filter_for_sink(file_references, sink) do
415+
file_references
416+
|> invert_references()
417+
|> do_filter_for_sink([{sink, nil}], %{})
418+
|> invert_references()
419+
end
420+
421+
defp do_filter_for_sink(file_references, new_nodes, acc) do
422+
Enum.reduce new_nodes, acc, fn {new_node_name, _type}, acc ->
423+
new_nodes = file_references[new_node_name]
424+
if acc[new_node_name] || !new_nodes do
425+
acc
426+
else
427+
do_filter_for_sink(file_references, new_nodes, Map.put(acc, new_node_name, new_nodes))
428+
end
429+
end
430+
end
431+
432+
defp invert_references(file_references) do
433+
Enum.reduce file_references, %{}, fn {file, references}, acc ->
434+
Enum.reduce references, acc, fn {reference, type}, acc ->
435+
Map.update(acc, reference, [{file, type}], &[{file, type} | &1])
436+
end
437+
end
438+
end
439+
286440
## Helpers
287441

288442
defp each_source_entries(entries_fun, pair_fun) do

lib/mix/lib/mix/utils.ex

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -142,31 +142,40 @@ defmodule Mix.Utils do
142142
end) |> Enum.uniq
143143
end
144144

145+
@type tree_node :: {name :: String.Chars.t, edge_info :: String.Chars.t}
146+
145147
@doc """
146148
Prints the given tree according to the callback.
147149
148150
The callback will be invoked for each node and it
149-
must either return `{printed, children}` tuple or
150-
`false` if the given node must not be printed.
151+
must return a `{printed, children}` tuple.
151152
"""
152-
@spec print_tree(term, (term -> {String.t, [term]}), Keyword.t) :: :ok
153-
def print_tree(root, callback, opts \\ []) do
153+
@spec print_tree([tree_node], (tree_node -> {tree_node, [tree_node]}), Keyword.t) :: :ok
154+
def print_tree(nodes, callback, opts \\ []) do
154155
pretty =
155156
case Keyword.get(opts, :format) do
156157
"pretty" -> true
157158
"plain" -> false
158159
_ -> elem(:os.type, 0) != :win32
159160
end
160-
print_tree([root], [], pretty, callback)
161+
print_tree(nodes, [], nil, MapSet.new(), pretty, callback)
162+
163+
:ok
161164
end
162165

163-
defp print_tree([], _depth, _pretty, _callback), do: :ok
164-
defp print_tree([node | nodes], depth, pretty, callback) do
165-
{{name, info}, children} = callback.(node)
166-
space = if info, do: " ", else: ""
167-
Mix.shell.info("#{depth(pretty, depth)}#{prefix(pretty, depth, nodes)}#{name}#{space}#{info}")
168-
print_tree(children, [(nodes != []) | depth], pretty, callback)
169-
print_tree(nodes, depth, pretty, callback)
166+
defp print_tree([], _depth, _parent, seen, _pretty, _callback), do: seen
167+
defp print_tree([node | nodes], depth, parent, seen, pretty, callback) do
168+
{{name, info}, children} = callback.(node)
169+
key = {parent, name}
170+
171+
if MapSet.member?(seen, key) do
172+
seen
173+
else
174+
space = if info, do: " ", else: ""
175+
Mix.shell.info("#{depth(pretty, depth)}#{prefix(pretty, depth, nodes)}#{name}#{space}#{info}")
176+
seen = print_tree(children, [(nodes != []) | depth], name, MapSet.put(seen, key), pretty, callback)
177+
print_tree(nodes, depth, parent, seen, pretty, callback)
178+
end
170179
end
171180

172181
defp depth(_pretty, []), do: ""
@@ -188,39 +197,44 @@ defmodule Mix.Utils do
188197
Outputs the given tree according to the callback as a DOT graph.
189198
190199
The callback will be invoked for each node and it
191-
must either return `{printed, children}` tuple or
192-
`false` if the given node must not be printed.
200+
must return a `{printed, children}` tuple.
193201
"""
194-
@spec write_dot_graph!(Path.t, String.t, term, (term -> {String.t, [term]}), Keyword.t) :: :ok
195-
def write_dot_graph!(path, title, root, callback, _opts \\ []) do
196-
{{parent, _}, children} = callback.(root)
197-
{dot, _} = build_dot_graph(parent, children, %{}, callback)
202+
@spec write_dot_graph!(Path.t, String.t, [tree_node], (tree_node -> {tree_node, [tree_node]}), Keyword.t) :: :ok
203+
def write_dot_graph!(path, title, nodes, callback, _opts \\ []) do
204+
{dot, _} = build_dot_graph(make_ref(), nodes, MapSet.new(), callback)
198205
File.write! path, "digraph \"#{title}\" {\n#{dot}}\n"
199206
end
200207

201208
defp build_dot_graph(_parent, [], seen, _callback), do: {"", seen}
202209
defp build_dot_graph(parent, [node | nodes], seen, callback) do
203210
{{name, edge_info}, children} = callback.(node)
204-
{current, seen} = build_dot_current(parent, name, edge_info, seen)
205-
{children, seen} = build_dot_graph(name, children, seen, callback)
206-
{siblings, seen} = build_dot_graph(parent, nodes, seen, callback)
207-
{current <> children <> siblings, seen}
208-
end
209-
210-
defp build_dot_current(parent, name, edge_info, seen) do
211211
key = {parent, name}
212-
case seen do
213-
%{^key => _} ->
214-
{"", seen}
215-
%{} when is_nil(edge_info) ->
216-
{~s( "#{parent}" -> "#{name}"\n),
217-
Map.put(seen, key, true)}
218-
%{} ->
219-
{~s( "#{parent}" -> "#{name}" [label=\"#{edge_info}\"]\n),
220-
Map.put(seen, key, true)}
212+
213+
if MapSet.member?(seen, key) do
214+
{"", seen}
215+
else
216+
seen = MapSet.put(seen, key)
217+
current = build_dot_current(parent, name, edge_info)
218+
{children, seen} = build_dot_graph(name, children, seen, callback)
219+
{siblings, seen} = build_dot_graph(parent, nodes, seen, callback)
220+
{current <> children <> siblings, seen}
221221
end
222222
end
223223

224+
defp build_dot_current(parent, name, edge_info) do
225+
edge_info =
226+
if edge_info do
227+
~s( [label="#{edge_info}"])
228+
end
229+
230+
parent =
231+
unless is_reference(parent) do
232+
~s("#{parent}" -> )
233+
end
234+
235+
~s( #{parent}"#{name}"#{edge_info}\n)
236+
end
237+
224238
@doc false
225239
# TODO: Deprecate by 1.4
226240
def underscore(value) do

lib/mix/test/mix/tasks/app.tree_test.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ defmodule Mix.Tasks.App.TreeTest do
8787

8888
assert File.read!("app_tree.dot") == """
8989
digraph "application tree" {
90+
"test"
9091
"test" -> "elixir"
9192
"test" -> "logger"
9293
"logger" -> "elixir"

lib/mix/test/mix/tasks/deps.tree_test.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ defmodule Mix.Tasks.Deps.TreeTest do
117117

118118
assert File.read!("deps_tree.dot") == """
119119
digraph "dependency tree" {
120+
"sample"
120121
"sample" -> "git_repo" [label=">= 0.1.0"]
121122
"sample" -> "deps_on_git_repo" [label="0.2.0"]
122123
}
@@ -127,6 +128,7 @@ defmodule Mix.Tasks.Deps.TreeTest do
127128

128129
assert File.read!("deps_tree.dot") == """
129130
digraph "dependency tree" {
131+
"sample"
130132
"sample" -> "git_repo" [label=">= 0.1.0"]
131133
"sample" -> "deps_on_git_repo" [label="0.2.0"]
132134
"deps_on_git_repo" -> "git_repo" [label=""]

0 commit comments

Comments
 (0)