Skip to content

Commit 0b98708

Browse files
committed
Support filtering mix deps output
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 0b98708

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)