Skip to main content

lib/npm/tree_format.ex

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