Skip to content

Commit e236830

Browse files
Josh Pricejosevalim
authored andcommitted
Option for mix app.tree and mix deps.tree to be output as DOT file (#4764)
1 parent 6810c20 commit e236830

File tree

5 files changed

+181
-41
lines changed

5 files changed

+181
-41
lines changed

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

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,17 @@ defmodule Mix.Tasks.App.Tree do
1717
* `--exclude` - exclude applications which you do not want to see printed.
1818
`kernel`, `stdlib` and `compiler` are always excluded from the tree.
1919
20-
* `--pretty` - use Unicode codepoints for formatting the tree.
21-
Defaults to `true` except on Windows.
20+
* `--format` - Can be set to one of either:
21+
22+
* `pretty` - use Unicode codepoints for formatting the tree.
23+
This is the default except on Windows.
24+
25+
* `plain` - do not use Unicode codepoints for formatting the tree.
26+
This is the default on Windows.
27+
28+
* `dot` - produces a DOT graph description of the application tree
29+
in `app_tree.dot` in the current directory.
30+
Warning: this will override any previously generated file.
2231
2332
"""
2433

@@ -29,7 +38,7 @@ defmodule Mix.Tasks.App.Tree do
2938
Mix.Task.run "compile"
3039

3140
{app, opts} =
32-
case OptionParser.parse!(args, strict: [exclude: :keep, pretty: :boolean]) do
41+
case OptionParser.parse!(args, strict: [exclude: :keep, format: :string]) do
3342
{opts, []} ->
3443
app = Mix.Project.config[:app] || Mix.raise("no application given and none found in mix.exs file")
3544
{app, opts}
@@ -40,10 +49,20 @@ defmodule Mix.Tasks.App.Tree do
4049
excluded = Keyword.get_values(opts, :exclude) |> Enum.map(&String.to_atom/1)
4150
excluded = @default_excluded ++ excluded
4251

43-
Mix.Utils.print_tree([{:normal, app}], fn {type, app} ->
44-
load(app)
45-
{"#{app}#{type(type)}", children_for(app, excluded)}
46-
end, opts)
52+
app_tree_callback =
53+
fn {type, app} ->
54+
load(app)
55+
{"#{app}#{type(type)}", children_for(app, excluded)}
56+
end
57+
58+
if opts[:format] == "dot" do
59+
app_tree = Mix.Utils.build_dot_graph("application tree", [{:normal, app}], app_tree_callback, opts)
60+
filename = "app_tree.dot"
61+
File.write!(filename, app_tree <> "\n")
62+
Mix.shell.info("Generated `#{filename}` in current directory")
63+
else
64+
Mix.Utils.print_tree([{:normal, app}], app_tree_callback, opts)
65+
end
4766
end
4867

4968
defp load(app) do

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

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,20 @@ defmodule Mix.Tasks.Deps.Tree do
1717
1818
* `--exclude` - exclude dependencies which you do not want to see printed.
1919
20-
* `--pretty` - use Unicode codepoints for formatting the tree.
21-
Defaults to `true` except on Windows.
20+
* `--format` - Can be set to one of either:
21+
22+
* `pretty` - use Unicode codepoints for formatting the tree.
23+
This is the default except on Windows.
24+
25+
* `plain` - do not use Unicode codepoints for formatting the tree.
26+
This is the default on Windows.
27+
28+
* `dot` - produces a DOT graph description of the dependency tree
29+
in `deps_tree.dot` in the current directory.
30+
Warning: this will override any previously generated file.
2231
2332
"""
24-
@switches [only: :string, exclude: :keep, pretty: :boolean]
33+
@switches [only: :string, exclude: :keep, format: :string]
2534

2635
@spec run(OptionParser.argv) :: :ok
2736
def run(args) do
@@ -42,29 +51,48 @@ defmodule Mix.Tasks.Deps.Tree do
4251
find_dep(deps, app) || Mix.raise("could not find dependency #{app}")
4352
end
4453

45-
Mix.Utils.print_tree([root], fn
46-
%Mix.Dep{app: app} = dep ->
47-
deps =
48-
# Do not show dependencies if they were
49-
# already show at the top level
50-
if not dep.top_level && find_dep(top_level, app) do
51-
[]
52-
else
53-
find_dep(deps, app).deps
54-
end
55-
{format_dep(dep), exclude(deps, excluded)}
56-
app ->
57-
{Atom.to_string(app), exclude(top_level, excluded)}
58-
end, opts)
54+
deps_tree_callback =
55+
fn
56+
%Mix.Dep{app: app} = dep ->
57+
deps =
58+
# Do not show dependencies if they were
59+
# already shown at the top level
60+
if not dep.top_level && find_dep(top_level, app) do
61+
[]
62+
else
63+
find_dep(deps, app).deps
64+
end
65+
{format_dep(dep, opts), exclude(deps, excluded)}
66+
app ->
67+
{Atom.to_string(app), exclude(top_level, excluded)}
68+
end
69+
70+
if opts[:format] == "dot" do
71+
deps_tree = Mix.Utils.build_dot_graph("dependency tree", [root], deps_tree_callback, opts)
72+
filename = "deps_tree.dot"
73+
File.write!(filename, deps_tree <> "\n")
74+
Mix.shell.info("Generated `#{filename}` in current directory")
75+
else
76+
Mix.Utils.print_tree([root], deps_tree_callback, opts)
77+
end
5978
end
6079

6180
defp exclude(deps, excluded) do
6281
Enum.reject deps, & &1.app in excluded
6382
end
6483

65-
defp format_dep(%{app: app, scm: scm, requirement: requirement, opts: opts}) do
66-
override = if opts[:override], do: "#{IO.ANSI.bright} *override*#{IO.ANSI.normal}", else: ""
67-
"#{app}#{requirement(requirement)} (#{scm.format(opts)})#{override}"
84+
defp format_dep(%{app: app, scm: scm, requirement: requirement, opts: deps_opts}, opts) do
85+
override =
86+
if deps_opts[:override] do
87+
"#{IO.ANSI.bright} *override*#{IO.ANSI.normal}"
88+
else
89+
""
90+
end
91+
if opts[:format] == "dot" do
92+
{app, "#{requirement(requirement)}#{override}"}
93+
else
94+
"#{app}#{requirement(requirement)} (#{scm.format(deps_opts)})#{override}"
95+
end
6896
end
6997

7098
defp requirement(nil), do: ""

lib/mix/lib/mix/utils.ex

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,12 @@ defmodule Mix.Utils do
151151
"""
152152
@spec print_tree([term], (term -> {String.t, [term]}), Keyword.t) :: :ok
153153
def print_tree(nodes, callback, opts \\ []) do
154-
pretty = Keyword.get(opts, :pretty, elem(:os.type, 0) != :win32)
154+
pretty =
155+
case Keyword.get(opts, :format) do
156+
"pretty" -> true
157+
"plain" -> false
158+
_ -> elem(:os.type, 0) != :win32
159+
end
155160
print_tree(nodes, [], pretty, callback)
156161
end
157162

@@ -178,6 +183,44 @@ defmodule Mix.Utils do
178183
defp prefix(true, _, []), do: "└── "
179184
defp prefix(true, _, _), do: "├── "
180185

186+
@doc """
187+
Outputs the given tree according to the callback as a DOT graph.
188+
189+
The callback will be invoked for each node and it
190+
must either return `{printed, children}` tuple or
191+
`false` if the given node must not be printed.
192+
"""
193+
@spec build_dot_graph(String.t, [term], (term -> {String.t, [term]}), Keyword.t) :: :ok
194+
def build_dot_graph(title, nodes, callback, _opts \\ []) do
195+
{parent_name, _} = callback.(hd(nodes))
196+
"digraph \"#{title}\" {\n" <>
197+
do_build_dot_graph(parent_name, nodes, callback) <>
198+
"}"
199+
end
200+
201+
defp do_build_dot_graph(_parent, [], _callback), do: ""
202+
defp do_build_dot_graph(parent, [node | nodes], callback) do
203+
{name, children} = callback.(node)
204+
205+
parent_name = case parent do
206+
{parent_name, _} -> parent_name
207+
parent_name -> parent_name
208+
end
209+
210+
if parent != name do
211+
case name do
212+
{node_name, edge_info} ->
213+
~s( "#{parent_name}" -> "#{node_name}" [label=\"#{edge_info}\"]\n)
214+
node_name ->
215+
~s( "#{parent_name}" -> "#{node_name}"\n)
216+
end
217+
else
218+
""
219+
end <>
220+
do_build_dot_graph(name, children, callback) <>
221+
do_build_dot_graph(parent, nodes, callback)
222+
end
223+
181224
@doc false
182225
# TODO: Deprecate by 1.4
183226
def underscore(value) do

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

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ defmodule Mix.Tasks.App.TreeTest do
1919

2020
in_tmp context.test, fn ->
2121
load_apps()
22-
Mix.Tasks.App.Tree.run(["--pretty"])
22+
Mix.Tasks.App.Tree.run(["--format", "pretty"])
2323

2424
assert_received {:mix_shell, :info, ["test"]}
2525
assert_received {:mix_shell, :info, ["└── app_deps_sample"]}
@@ -33,7 +33,7 @@ defmodule Mix.Tasks.App.TreeTest do
3333
test "show the application tree for umbrella apps" do
3434
in_fixture "umbrella_dep/deps/umbrella", fn ->
3535
Mix.Project.in_project(:umbrella, ".", fn _ ->
36-
Mix.Task.run "app.tree", ["--pretty"]
36+
Mix.Task.run "app.tree", ["--format", "pretty"]
3737
assert_received {:mix_shell, :info, ["foo"]}
3838
assert_received {:mix_shell, :info, ["└── elixir"]}
3939
assert_received {:mix_shell, :info, ["bar"]}
@@ -48,11 +48,11 @@ defmodule Mix.Tasks.App.TreeTest do
4848

4949
in_tmp context.test, fn ->
5050
assert_raise Mix.Error, "could not find application app_deps_sample", fn ->
51-
Mix.Tasks.App.Tree.run(["--pretty", "app_deps_sample"])
51+
Mix.Tasks.App.Tree.run(["--format", "pretty", "app_deps_sample"])
5252
end
5353

5454
load_apps()
55-
Mix.Tasks.App.Tree.run(["--pretty", "app_deps_sample"])
55+
Mix.Tasks.App.Tree.run(["--format", "pretty", "app_deps_sample"])
5656

5757
assert_received {:mix_shell, :info, ["app_deps_sample"]}
5858
assert_received {:mix_shell, :info, ["├── app_deps2_sample"]}
@@ -67,7 +67,7 @@ defmodule Mix.Tasks.App.TreeTest do
6767

6868
in_tmp context.test, fn ->
6969
load_apps()
70-
Mix.Tasks.App.Tree.run(["--pretty", "--exclude", "app_deps4_sample", "--exclude", "app_deps3_sample"])
70+
Mix.Tasks.App.Tree.run(["--format", "pretty", "--exclude", "app_deps4_sample", "--exclude", "app_deps3_sample"])
7171

7272
assert_received {:mix_shell, :info, ["test"]}
7373
assert_received {:mix_shell, :info, ["└── app_deps_sample"]}
@@ -77,6 +77,28 @@ defmodule Mix.Tasks.App.TreeTest do
7777
end
7878
end
7979

80+
@tag apps: [:test, :app_deps_sample, :app_deps2_sample, :app_deps3_sample, :app_deps4_sample]
81+
test "shows the application tree in dot form", context do
82+
Mix.Project.push AppDepsSample
83+
84+
in_tmp context.test, fn ->
85+
load_apps()
86+
Mix.Tasks.App.Tree.run(["--format", "dot"])
87+
88+
assert File.read!("app_tree.dot") == """
89+
digraph "application tree" {
90+
"test" -> "elixir"
91+
"test" -> "logger"
92+
"logger" -> "elixir"
93+
"test" -> "app_deps_sample"
94+
"app_deps_sample" -> "app_deps2_sample"
95+
"app_deps2_sample" -> "app_deps4_sample (included)"
96+
"app_deps_sample" -> "app_deps3_sample"
97+
}
98+
"""
99+
end
100+
end
101+
80102
def load_apps() do
81103
:ok = :application.load({:application, :app_deps4_sample, [vsn: '1.0.0', env: []]})
82104
:ok = :application.load({:application, :app_deps3_sample, [vsn: '1.0.0', env: []]})

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

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,14 @@ defmodule Mix.Tasks.Deps.TreeTest do
3333
Mix.Project.push ConvergedDepsApp
3434

3535
in_tmp context.test, fn ->
36-
Mix.Tasks.Deps.Tree.run(["--pretty"])
36+
Mix.Tasks.Deps.Tree.run(["--format", "pretty"])
3737
assert_received {:mix_shell, :info, ["sample"]}
3838
assert_received {:mix_shell, :info, ["├── git_repo >= 0.1.0 (" <> _]}
3939
assert_received {:mix_shell, :info, ["└── deps_on_git_repo 0.2.0 (" <> _]}
4040
refute_received {:mix_shell, :info, [" └── git_repo (" <> _]}
4141

4242
Mix.Tasks.Deps.Get.run([])
43-
Mix.Tasks.Deps.Tree.run(["--pretty"])
43+
Mix.Tasks.Deps.Tree.run(["--format", "pretty"])
4444
assert_received {:mix_shell, :info, ["sample"]}
4545
assert_received {:mix_shell, :info, ["├── git_repo >= 0.1.0 (" <> _]}
4646
assert_received {:mix_shell, :info, ["└── deps_on_git_repo 0.2.0 (" <> _]}
@@ -53,7 +53,7 @@ defmodule Mix.Tasks.Deps.TreeTest do
5353
test "show the dependency tree for umbrella apps" do
5454
in_fixture "umbrella_dep/deps/umbrella", fn ->
5555
Mix.Project.in_project(:umbrella, ".", fn _ ->
56-
Mix.Task.run "deps.tree", ["--pretty"]
56+
Mix.Task.run "deps.tree", ["--format", "pretty"]
5757
assert_received {:mix_shell, :info, ["foo"]}
5858
assert_received {:mix_shell, :info, ["bar"]}
5959
assert_received {:mix_shell, :info, ["└── foo (../foo)"]}
@@ -66,10 +66,10 @@ defmodule Mix.Tasks.Deps.TreeTest do
6666

6767
in_tmp context.test, fn ->
6868
assert_raise Mix.Error, "could not find dependency unknown", fn ->
69-
Mix.Tasks.Deps.Tree.run(["--pretty", "unknown"])
69+
Mix.Tasks.Deps.Tree.run(["--format", "pretty", "unknown"])
7070
end
7171

72-
Mix.Tasks.Deps.Tree.run(["--pretty", "deps_on_git_repo"])
72+
Mix.Tasks.Deps.Tree.run(["--format", "pretty", "deps_on_git_repo"])
7373
assert_received {:mix_shell, :info, ["deps_on_git_repo 0.2.0 (" <> _]}
7474
refute_received {:mix_shell, :info, ["└── git_repo (" <> _]}
7575
end
@@ -79,7 +79,7 @@ defmodule Mix.Tasks.Deps.TreeTest do
7979
Mix.Project.push OverriddenDepsApp
8080

8181
in_tmp context.test, fn ->
82-
Mix.Tasks.Deps.Tree.run(["--pretty"])
82+
Mix.Tasks.Deps.Tree.run(["--format", "pretty"])
8383
assert_received {:mix_shell, :info, ["sample"]}
8484
assert_received {:mix_shell, :info, ["├── git_repo (" <> msg]}
8585
assert_received {:mix_shell, :info, ["└── deps_on_git_repo ~r/0.2.0/ (" <> _]}
@@ -91,7 +91,7 @@ defmodule Mix.Tasks.Deps.TreeTest do
9191
Mix.Project.push OverriddenDepsApp
9292

9393
in_tmp context.test, fn ->
94-
Mix.Tasks.Deps.Tree.run(["--pretty", "--exclude", "deps_on_git_repo"])
94+
Mix.Tasks.Deps.Tree.run(["--format", "pretty", "--exclude", "deps_on_git_repo"])
9595
assert_received {:mix_shell, :info, ["sample"]}
9696
assert_received {:mix_shell, :info, ["└── git_repo (" <> _]}
9797
refute_received {:mix_shell, :info, ["└── deps_on_git_repo ~r/0.2.0/ (" <> _]}
@@ -102,10 +102,38 @@ defmodule Mix.Tasks.Deps.TreeTest do
102102
Mix.Project.push OverriddenDepsApp
103103

104104
in_tmp context.test, fn ->
105-
Mix.Tasks.Deps.Tree.run(["--pretty", "--only", "prod"])
105+
Mix.Tasks.Deps.Tree.run(["--format", "pretty", "--only", "prod"])
106106
assert_received {:mix_shell, :info, ["sample"]}
107107
assert_received {:mix_shell, :info, ["└── git_repo (" <> _]}
108108
refute_received {:mix_shell, :info, ["└── deps_on_git_repo ~r/0.2.0/ (" <> _]}
109109
end
110110
end
111+
112+
test "shows the dependency tree in DOT graph format", context do
113+
Mix.Project.push ConvergedDepsApp
114+
115+
in_tmp context.test, fn ->
116+
Mix.Tasks.Deps.Tree.run(["--format", "dot"])
117+
118+
assert File.read!("deps_tree.dot") == """
119+
digraph "dependency tree" {
120+
"sample" -> "git_repo" [label=" >= 0.1.0"]
121+
"sample" -> "deps_on_git_repo" [label=" 0.2.0"]
122+
}
123+
"""
124+
125+
Mix.Tasks.Deps.Get.run([])
126+
Mix.Tasks.Deps.Tree.run(["--format", "dot"])
127+
128+
assert File.read!("deps_tree.dot") == """
129+
digraph "dependency tree" {
130+
"sample" -> "git_repo" [label=" >= 0.1.0"]
131+
"sample" -> "deps_on_git_repo" [label=" 0.2.0"]
132+
"deps_on_git_repo" -> "git_repo" [label=""]
133+
}
134+
"""
135+
end
136+
after
137+
purge [DepsOnGitRepo.Mixfile, GitRepo.Mixfile]
138+
end
111139
end

0 commit comments

Comments
 (0)