Skip to main content

lib/mix/tasks/npm.tree.ex

defmodule Mix.Tasks.Npm.Tree do
  @shortdoc "Show dependency tree"

  @moduledoc """
  Display the full dependency tree from `npm.lock`.

      mix npm.tree

  Shows which packages depend on which, with version info.
  """

  use Mix.Task

  @impl true
  def run([]) do
    Application.ensure_all_started(:req)

    with {:ok, project} <- NPM.Workspace.read_all(),
         {:ok, lockfile} <- NPM.Lockfile.read() do
      if lockfile == %{} do
        Mix.shell().info("No packages installed.")
      else
        print_tree(project.dependencies, project.dev_dependencies, lockfile)
      end
    end
  end

  def run(_) do
    Mix.shell().error("Usage: mix npm.tree")
  end

  defp print_tree(deps, dev_deps, lockfile) do
    print_group("dependencies", deps, lockfile)
    print_group("devDependencies", dev_deps, lockfile)
  end

  defp print_group(_label, group, _lockfile) when map_size(group) == 0, do: :ok

  defp print_group(label, group, lockfile) do
    Mix.shell().info(label <> ":")

    group
    |> Enum.sort_by(&elem(&1, 0))
    |> Enum.each(fn {name, _range} ->
      version = get_in(lockfile, [name, Access.key(:version)]) || "?"
      Mix.shell().info("├── #{name}@#{version}")
      print_subtree(name, lockfile, "│   ", MapSet.new([name]))
    end)
  end

  defp print_subtree(name, lockfile, prefix, seen) do
    case Map.get(lockfile, name) do
      nil -> :ok
      entry -> print_deps_tree(entry.dependencies, lockfile, prefix, seen)
    end
  end

  defp print_deps_tree(deps, lockfile, prefix, seen) do
    deps
    |> Enum.sort_by(&elem(&1, 0))
    |> Enum.each(&print_dep(&1, lockfile, prefix, seen))
  end

  defp print_dep({dep_name, _range}, lockfile, prefix, seen) do
    dep_version = get_in(lockfile, [dep_name, Access.key(:version)]) || "?"

    if MapSet.member?(seen, dep_name) do
      Mix.shell().info("#{prefix}└── #{dep_name}@#{dep_version} (circular)")
    else
      Mix.shell().info("#{prefix}└── #{dep_name}@#{dep_version}")
      print_subtree(dep_name, lockfile, prefix <> "    ", MapSet.put(seen, dep_name))
    end
  end
end