Skip to main content

lib/npm/dependency/tree.ex

defmodule NPM.Dependency.Tree do
  @moduledoc """
  Build and query a dependency tree from the lockfile.

  Provides a structured view of the dependency graph, useful
  for `mix npm.tree`, `mix npm.why`, and deduplication.
  """

  @type tree_node :: %{
          name: String.t(),
          version: String.t(),
          children: [tree_node()]
        }

  @doc """
  Build a dependency tree from the lockfile.

  Returns a list of root-level nodes, each with their transitive
  dependencies as children.
  """
  @spec build(%{String.t() => NPM.Lockfile.entry()}, %{String.t() => String.t()}) :: [tree_node()]
  def build(lockfile, root_deps) do
    root_deps
    |> Enum.sort_by(&elem(&1, 0))
    |> Enum.map(fn {name, _range} -> build_node(name, lockfile, MapSet.new()) end)
    |> Enum.reject(&is_nil/1)
  end

  @doc """
  Flatten the tree into a list of all package names.
  """
  @spec flatten([tree_node()]) :: [String.t()]
  def flatten(nodes) do
    nodes
    |> Enum.flat_map(fn node -> [node.name | flatten(node.children)] end)
    |> Enum.uniq()
    |> Enum.sort()
  end

  @doc """
  Find all paths from root to a target package.
  """
  @spec paths_to([tree_node()], String.t()) :: [[String.t()]]
  def paths_to(nodes, target) do
    Enum.flat_map(nodes, &find_paths(&1, target, []))
  end

  @doc """
  Get the depth of a package in the tree (0 = root dep).
  """
  @spec depth([tree_node()], String.t()) :: non_neg_integer() | nil
  def depth(nodes, target) do
    case paths_to(nodes, target) do
      [] -> nil
      paths -> paths |> Enum.map(&(length(&1) - 1)) |> Enum.min()
    end
  end

  @doc """
  Count total unique packages in the tree.
  """
  @spec count([tree_node()]) :: non_neg_integer()
  def count(nodes), do: flatten(nodes) |> length()

  defp build_node(name, lockfile, visited) do
    if MapSet.member?(visited, name),
      do: nil,
      else: do_build_node(name, lockfile, visited)
  end

  defp do_build_node(name, lockfile, visited) do
    case Map.get(lockfile, name) do
      nil ->
        nil

      entry ->
        children = build_children(entry.dependencies, lockfile, MapSet.put(visited, name))
        %{name: name, version: entry.version, children: children}
    end
  end

  defp build_children(deps, lockfile, visited) do
    deps
    |> Enum.sort_by(&elem(&1, 0))
    |> Enum.map(fn {dep_name, _} -> build_node(dep_name, lockfile, visited) end)
    |> Enum.reject(&is_nil/1)
  end

  defp find_paths(node, target, path) do
    current_path = path ++ [node.name]

    own = if node.name == target, do: [current_path], else: []

    child_paths = Enum.flat_map(node.children, &find_paths(&1, target, current_path))

    own ++ child_paths
  end
end