Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions lib/mix/lib/mix/tasks/deps.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ defmodule Mix.Tasks.Deps do
@shortdoc "Lists dependencies and their status"

@moduledoc ~S"""
Lists all dependencies and their status.
Lists dependencies and their status.

Dependencies must be specified in the `mix.exs` file in one of
the following formats:
Expand Down Expand Up @@ -171,7 +171,7 @@ defmodule Mix.Tasks.Deps do

## Deps task

`mix deps` task lists all dependencies in the following format:
The `mix deps` task lists dependencies in the following format:

APP VERSION (SCM) (MANAGER)
[locked at REF]
Expand All @@ -186,19 +186,40 @@ defmodule Mix.Tasks.Deps do

* `--all` - lists all dependencies, regardless of specified environment

### Listing specific dependencies (since v1.20.0)

Pass dependency names as arguments to filter the output:

$ mix deps phoenix phoenix_live_view

This is particularly useful when you need to quickly check versions for
bug reports or similar tasks.
"""

@impl true
def run(args) do
Mix.Project.get!()
{opts, _, _} = OptionParser.parse(args, switches: [all: :boolean])
{opts, apps, _} = OptionParser.parse(args, switches: [all: :boolean])
loaded_opts = if opts[:all], do: [], else: [env: Mix.env(), target: Mix.target()]

deps = Mix.Dep.Converger.converge(loaded_opts)

apps =
apps
|> Enum.map(&String.to_atom/1)
|> Enum.uniq()

# Sort deps when showing all or preserve input order when filtering
{deps, unknown} =
if apps == [] do
{Enum.sort_by(deps, & &1.app), []}
else
filter(deps, apps)
end

shell = Mix.shell()

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

shell.info(" #{format_status(dep)}")
end)

# Warnings are displayed at the end for visibility
for app <- unknown do
shell.error("warning: unknown dependency #{app}")
end
end

defp filter(deps, apps) do
selected_deps =
Enum.flat_map(apps, fn app -> List.wrap(Enum.find(deps, &(&1.app == app))) end)

unknown_apps = apps -- Enum.map(selected_deps, & &1.app)

{selected_deps, unknown_apps}
end
end
144 changes: 144 additions & 0 deletions lib/mix/test/mix/tasks/deps_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,79 @@ defmodule Mix.Tasks.DepsTest do
end)
end

test "filters dependencies by name" do
in_fixture("deps_status", fn ->
Mix.Project.push(DepsApp)

Mix.Tasks.Deps.run(["ok"])

assert_received {:mix_shell, :info, ["* ok (https://github.com/elixir-lang/ok.git) (mix)"]}
refute_received {:mix_shell, :info, ["* invalidvsn" <> _]}
refute_received {:mix_shell, :info, ["* invalidapp" <> _]}
refute_received {:mix_shell, :info, ["* noappfile" <> _]}
refute_received {:mix_shell, :info, ["* nosemver" <> _]}
end)
end

test "warns when filtering for unknown dependencies" do
in_fixture("deps_status", fn ->
Mix.Project.push(DepsApp)

Mix.Tasks.Deps.run(["ok", "unknowndep"])

assert_received {:mix_shell, :info, ["* ok (https://github.com/elixir-lang/ok.git) (mix)"]}
assert_received {:mix_shell, :error, ["warning: unknown dependency unknowndep"]}
end)
end

test "filters dependencies preserving argument order" do
in_fixture("deps_status", fn ->
Mix.Project.push(DepsApp)

Mix.Tasks.Deps.run(["nosemver", "unknowndep2", "ok", "unknowndep1", "invalidapp"])

assert_output_order([
{:info, "nosemver"},
{:info, "ok"},
{:info, "invalidapp"},
{:error, "unknowndep2"},
{:error, "unknowndep1"}
])
end)
end

test "deduplicates arguments preserving first occurrence order" do
in_fixture("deps_status", fn ->
Mix.Project.push(DepsApp)

Mix.Tasks.Deps.run(["ok", "ok", "nosemver", "unknowndep", "ok", "unknowndep"])

messages = receive_shell_messages()

assert_output_once(messages, "ok")
assert_output_once(messages, "nosemver")
assert_output_once(messages, "unknowndep")

assert_output_order(messages, [{:info, "ok"}, {:info, "nosemver"}, {:error, "unknowndep"}])
end)
end

test "lists all dependencies in alphabetical order when no filter is given" do
in_fixture("deps_status", fn ->
Mix.Project.push(DepsApp)

Mix.Tasks.Deps.run([])

assert_output_order([
{:info, "invalidapp"},
{:info, "invalidvsn"},
{:info, "noappfile"},
{:info, "nosemver"},
{:info, "ok"}
])
end)
end

test "prints list of dependencies and their status, including req mismatches and custom apps" do
in_fixture("deps_status", fn ->
Mix.Project.push(ReqDepsApp)
Expand Down Expand Up @@ -977,4 +1050,75 @@ defmodule Mix.Tasks.DepsTest do
assert File.exists?("deps/ok")
end)
end

## Helpers

defp assert_output_once(messages, dep) do
matches = Enum.count(messages, &(info_message?(&1, dep) or warning_message?(&1, dep)))

if matches != 1 do
flunk("""
Expected output for #{dep} to appear only once!

Output:
#{inspect(messages)}
""")
end
end

defp assert_output_order(expected) do
assert_output_order(receive_shell_messages(), expected)
end

defp assert_output_order(output, expected) do
# Find the index of each expected output line
indices =
Enum.map(expected, fn
{:info, dep} -> Enum.find_index(output, &info_message?(&1, dep))
{:error, dep} -> Enum.find_index(output, &warning_message?(&1, dep))
end)

if not Enum.all?(indices) do
flunk("""
Expected output not found!

Expected:
#{inspect(expected)}

Output:
#{inspect(output)}
""")
end

if not strictly_increasing?(indices) do
flunk("""
Output not in expected order!

Expected:
#{inspect(expected)}

Output:
#{inspect(output)}
""")
end
end

defp receive_shell_messages(acc \\ []) do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use Process.info(self(), :messages) to get all messages, and then pattern matching on the list. It should lead to a cleaner test, I will rewrite that after merging!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to borrow from other tests, e.g.

defp receive_until_no_messages(acc) do
receive do
{:mix_shell, :info, [line]} -> receive_until_no_messages([acc, line | "\n"])
after
0 -> IO.iodata_to_binary(acc)
end
end

In case you want to look at them as a greater whole when rewriting!

Thanks for the review rounds! 💜

receive do
{:mix_shell, level, [line]} when level in [:info, :error] ->
receive_shell_messages([{level, line} | acc])
after
0 -> Enum.reverse(acc)
end
end

defp info_message?({:info, line}, dep), do: line =~ ~r/^\* #{dep} /
defp info_message?(_message, _dep), do: false

defp warning_message?({:error, line}, dep), do: line =~ ~r/^warning: .* #{dep}/
defp warning_message?(_message, _dep), do: false

defp strictly_increasing?([]), do: true
defp strictly_increasing?([_]), do: true
defp strictly_increasing?([a, b | rest]), do: a < b and strictly_increasing?([b | rest])
end
Loading