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