Skip to content

Commit 0435602

Browse files
authored
Support filtering mix deps output (#15009)
Makes it easier to report versions of specific dependencies by allowing users (or tools) to pass dependency names as arguments to `mix deps`. Warns when a dependency is not found, similar to `mix deps.unlock`. Proposal: https://groups.google.com/g/elixir-lang-core/c/5tlLZ1yu4rQ/m/g7Z8fNWiBwAJ
1 parent b60e424 commit 0435602

File tree

2 files changed

+185
-6
lines changed

2 files changed

+185
-6
lines changed

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

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ defmodule Mix.Tasks.Deps do
1010
@shortdoc "Lists dependencies and their status"
1111

1212
@moduledoc ~S"""
13-
Lists all dependencies and their status.
13+
Lists dependencies and their status.
1414
1515
Dependencies must be specified in the `mix.exs` file in one of
1616
the following formats:
@@ -171,7 +171,7 @@ defmodule Mix.Tasks.Deps do
171171
172172
## Deps task
173173
174-
`mix deps` task lists all dependencies in the following format:
174+
The `mix deps` task lists dependencies in the following format:
175175
176176
APP VERSION (SCM) (MANAGER)
177177
[locked at REF]
@@ -186,19 +186,40 @@ defmodule Mix.Tasks.Deps do
186186
187187
* `--all` - lists all dependencies, regardless of specified environment
188188
189+
### Listing specific dependencies (since v1.20.0)
190+
191+
Pass dependency names as arguments to filter the output:
192+
193+
$ mix deps phoenix phoenix_live_view
194+
195+
This is particularly useful when you need to quickly check versions for
196+
bug reports or similar tasks.
189197
"""
190198

191199
@impl true
192200
def run(args) do
193201
Mix.Project.get!()
194-
{opts, _, _} = OptionParser.parse(args, switches: [all: :boolean])
202+
{opts, apps, _} = OptionParser.parse(args, switches: [all: :boolean])
195203
loaded_opts = if opts[:all], do: [], else: [env: Mix.env(), target: Mix.target()]
196204

205+
deps = Mix.Dep.Converger.converge(loaded_opts)
206+
207+
apps =
208+
apps
209+
|> Enum.map(&String.to_atom/1)
210+
|> Enum.uniq()
211+
212+
# Sort deps when showing all or preserve input order when filtering
213+
{deps, unknown} =
214+
if apps == [] do
215+
{Enum.sort_by(deps, & &1.app), []}
216+
else
217+
filter(deps, apps)
218+
end
219+
197220
shell = Mix.shell()
198221

199-
Mix.Dep.Converger.converge(loaded_opts)
200-
|> Enum.sort_by(& &1.app)
201-
|> Enum.each(fn dep ->
222+
Enum.each(deps, fn dep ->
202223
%Mix.Dep{scm: scm, manager: manager} = dep
203224
dep = check_lock(dep)
204225
extra = if manager, do: " (#{manager})", else: ""
@@ -211,5 +232,19 @@ defmodule Mix.Tasks.Deps do
211232

212233
shell.info(" #{format_status(dep)}")
213234
end)
235+
236+
# Warnings are displayed at the end for visibility
237+
for app <- unknown do
238+
shell.error("warning: unknown dependency #{app}")
239+
end
240+
end
241+
242+
defp filter(deps, apps) do
243+
selected_deps =
244+
Enum.flat_map(apps, fn app -> List.wrap(Enum.find(deps, &(&1.app == app))) end)
245+
246+
unknown_apps = apps -- Enum.map(selected_deps, & &1.app)
247+
248+
{selected_deps, unknown_apps}
214249
end
215250
end

lib/mix/test/mix/tasks/deps_test.exs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,79 @@ defmodule Mix.Tasks.DepsTest do
100100
end)
101101
end
102102

103+
test "filters dependencies by name" do
104+
in_fixture("deps_status", fn ->
105+
Mix.Project.push(DepsApp)
106+
107+
Mix.Tasks.Deps.run(["ok"])
108+
109+
assert_received {:mix_shell, :info, ["* ok (https://github.com/elixir-lang/ok.git) (mix)"]}
110+
refute_received {:mix_shell, :info, ["* invalidvsn" <> _]}
111+
refute_received {:mix_shell, :info, ["* invalidapp" <> _]}
112+
refute_received {:mix_shell, :info, ["* noappfile" <> _]}
113+
refute_received {:mix_shell, :info, ["* nosemver" <> _]}
114+
end)
115+
end
116+
117+
test "warns when filtering for unknown dependencies" do
118+
in_fixture("deps_status", fn ->
119+
Mix.Project.push(DepsApp)
120+
121+
Mix.Tasks.Deps.run(["ok", "unknowndep"])
122+
123+
assert_received {:mix_shell, :info, ["* ok (https://github.com/elixir-lang/ok.git) (mix)"]}
124+
assert_received {:mix_shell, :error, ["warning: unknown dependency unknowndep"]}
125+
end)
126+
end
127+
128+
test "filters dependencies preserving argument order" do
129+
in_fixture("deps_status", fn ->
130+
Mix.Project.push(DepsApp)
131+
132+
Mix.Tasks.Deps.run(["nosemver", "unknowndep2", "ok", "unknowndep1", "invalidapp"])
133+
134+
assert_output_order([
135+
{:info, "nosemver"},
136+
{:info, "ok"},
137+
{:info, "invalidapp"},
138+
{:error, "unknowndep2"},
139+
{:error, "unknowndep1"}
140+
])
141+
end)
142+
end
143+
144+
test "deduplicates arguments preserving first occurrence order" do
145+
in_fixture("deps_status", fn ->
146+
Mix.Project.push(DepsApp)
147+
148+
Mix.Tasks.Deps.run(["ok", "ok", "nosemver", "unknowndep", "ok", "unknowndep"])
149+
150+
messages = receive_shell_messages()
151+
152+
assert_output_once(messages, "ok")
153+
assert_output_once(messages, "nosemver")
154+
assert_output_once(messages, "unknowndep")
155+
156+
assert_output_order(messages, [{:info, "ok"}, {:info, "nosemver"}, {:error, "unknowndep"}])
157+
end)
158+
end
159+
160+
test "lists all dependencies in alphabetical order when no filter is given" do
161+
in_fixture("deps_status", fn ->
162+
Mix.Project.push(DepsApp)
163+
164+
Mix.Tasks.Deps.run([])
165+
166+
assert_output_order([
167+
{:info, "invalidapp"},
168+
{:info, "invalidvsn"},
169+
{:info, "noappfile"},
170+
{:info, "nosemver"},
171+
{:info, "ok"}
172+
])
173+
end)
174+
end
175+
103176
test "prints list of dependencies and their status, including req mismatches and custom apps" do
104177
in_fixture("deps_status", fn ->
105178
Mix.Project.push(ReqDepsApp)
@@ -977,4 +1050,75 @@ defmodule Mix.Tasks.DepsTest do
9771050
assert File.exists?("deps/ok")
9781051
end)
9791052
end
1053+
1054+
## Helpers
1055+
1056+
defp assert_output_once(messages, dep) do
1057+
matches = Enum.count(messages, &(info_message?(&1, dep) or warning_message?(&1, dep)))
1058+
1059+
if matches != 1 do
1060+
flunk("""
1061+
Expected output for #{dep} to appear only once!
1062+
1063+
Output:
1064+
#{inspect(messages)}
1065+
""")
1066+
end
1067+
end
1068+
1069+
defp assert_output_order(expected) do
1070+
assert_output_order(receive_shell_messages(), expected)
1071+
end
1072+
1073+
defp assert_output_order(output, expected) do
1074+
# Find the index of each expected output line
1075+
indices =
1076+
Enum.map(expected, fn
1077+
{:info, dep} -> Enum.find_index(output, &info_message?(&1, dep))
1078+
{:error, dep} -> Enum.find_index(output, &warning_message?(&1, dep))
1079+
end)
1080+
1081+
if not Enum.all?(indices) do
1082+
flunk("""
1083+
Expected output not found!
1084+
1085+
Expected:
1086+
#{inspect(expected)}
1087+
1088+
Output:
1089+
#{inspect(output)}
1090+
""")
1091+
end
1092+
1093+
if not strictly_increasing?(indices) do
1094+
flunk("""
1095+
Output not in expected order!
1096+
1097+
Expected:
1098+
#{inspect(expected)}
1099+
1100+
Output:
1101+
#{inspect(output)}
1102+
""")
1103+
end
1104+
end
1105+
1106+
defp receive_shell_messages(acc \\ []) do
1107+
receive do
1108+
{:mix_shell, level, [line]} when level in [:info, :error] ->
1109+
receive_shell_messages([{level, line} | acc])
1110+
after
1111+
0 -> Enum.reverse(acc)
1112+
end
1113+
end
1114+
1115+
defp info_message?({:info, line}, dep), do: line =~ ~r/^\* #{dep} /
1116+
defp info_message?(_message, _dep), do: false
1117+
1118+
defp warning_message?({:error, line}, dep), do: line =~ ~r/^warning: .* #{dep}/
1119+
defp warning_message?(_message, _dep), do: false
1120+
1121+
defp strictly_increasing?([]), do: true
1122+
defp strictly_increasing?([_]), do: true
1123+
defp strictly_increasing?([a, b | rest]), do: a < b and strictly_increasing?([b | rest])
9801124
end

0 commit comments

Comments
 (0)