From 0b98708a22941676bc1218e569a2709e1b610f57 Mon Sep 17 00:00:00 2001 From: Rodolfo Carvalho Date: Thu, 11 Dec 2025 17:31:36 +0100 Subject: [PATCH] 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 --- lib/mix/lib/mix/tasks/deps.ex | 47 +++++++-- lib/mix/test/mix/tasks/deps_test.exs | 144 +++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/lib/mix/lib/mix/tasks/deps.ex b/lib/mix/lib/mix/tasks/deps.ex index f17cd078cc2..82d4e42fc48 100644 --- a/lib/mix/lib/mix/tasks/deps.ex +++ b/lib/mix/lib/mix/tasks/deps.ex @@ -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: @@ -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] @@ -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: "" @@ -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 diff --git a/lib/mix/test/mix/tasks/deps_test.exs b/lib/mix/test/mix/tasks/deps_test.exs index 280e97dbf50..14a24f90d92 100644 --- a/lib/mix/test/mix/tasks/deps_test.exs +++ b/lib/mix/test/mix/tasks/deps_test.exs @@ -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) @@ -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 + 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