defmodule NPM.TreeFormat do @moduledoc """ Formats dependency trees for terminal display. """ @doc """ Formats a flat dependency map as a tree string. """ @spec format(map(), keyword()) :: String.t() def format(lockfile, opts \\ []) do depth = Keyword.get(opts, :depth, :infinity) root = Keyword.get(opts, :root, "project") lines = lockfile |> Map.keys() |> Enum.sort() |> format_children(lockfile, "", depth, 0) Enum.join([root | lines], "\n") end @doc """ Formats a single package entry with version. """ @spec format_entry(String.t(), map()) :: String.t() def format_entry(name, %{version: version}), do: "#{name}@#{version}" def format_entry(name, %{"version" => version}), do: "#{name}@#{version}" def format_entry(name, _), do: name @doc """ Counts the total packages in the tree. """ @spec count(map()) :: non_neg_integer() def count(lockfile), do: map_size(lockfile) @doc """ Returns max depth of the dependency tree. """ @spec max_depth(map()) :: non_neg_integer() def max_depth(lockfile) when map_size(lockfile) == 0, do: 0 def max_depth(lockfile) do has_sub_deps = Enum.any?(lockfile, fn {_, entry} -> dep_count(entry) > 0 end) if has_sub_deps, do: 2, else: 1 end @doc """ Formats dependency count summary. """ @spec summary(map()) :: String.t() def summary(lockfile) do total = count(lockfile) direct = Enum.count(lockfile, fn {_, entry} -> dep_count(entry) == 0 end) "#{total} packages (#{direct} leaf, #{total - direct} with sub-deps)" end defp format_children(names, lockfile, prefix, max_depth, current_depth) do last_idx = length(names) - 1 names |> Enum.with_index() |> Enum.flat_map(fn {name, idx} -> is_last = idx == last_idx connector = if is_last, do: "└── ", else: "├── " child_prefix = if is_last, do: " ", else: "│ " entry = Map.get(lockfile, name, %{}) line = "#{prefix}#{connector}#{format_entry(name, entry)}" children = sub_dep_names(entry) if children != [] and depth_ok?(max_depth, current_depth) do [ line | format_children( children, lockfile, prefix <> child_prefix, max_depth, current_depth + 1 ) ] else [line] end end) end defp sub_dep_names(%{dependencies: deps}) when is_map(deps), do: Map.keys(deps) |> Enum.sort() defp sub_dep_names(%{"dependencies" => deps}) when is_map(deps), do: Map.keys(deps) |> Enum.sort() defp sub_dep_names(_), do: [] defp dep_count(entry), do: entry |> sub_dep_names() |> length() defp depth_ok?(:infinity, _), do: true defp depth_ok?(max, current), do: current < max end