Skip to main content

lib/npm/dependency/sort.ex

defmodule NPM.Dependency.Sort do
  alias NPM.Dependency.Graph

  @moduledoc """
  Topological sorting of packages for correct install/build order.
  """

  @doc """
  Topologically sorts packages so dependencies come before dependents.
  """
  @spec sort(map()) :: {:ok, [String.t()]} | {:error, :cycle}
  def sort(adj) do
    in_degree = compute_in_degree(adj)

    queue =
      in_degree |> Enum.filter(fn {_, d} -> d == 0 end) |> Enum.map(&elem(&1, 0)) |> Enum.sort()

    topo_sort(adj, in_degree, queue, [])
  end

  @doc """
  Returns install order — topological sort where leaves come first.
  """
  @spec install_order(map()) :: [String.t()]
  def install_order(adj) do
    {:ok, order} = sort(adj)
    Enum.reverse(order)
  end

  @doc """
  Returns build order levels — packages that can be built in parallel.
  """
  @spec parallel_levels(map()) :: [[String.t()]]
  def parallel_levels(adj) do
    rev = Graph.reverse(adj)
    in_degree = compute_in_degree(rev)
    build_levels(rev, in_degree, [])
  end

  @doc """
  Counts the number of levels (maximum parallelism depth).
  """
  @spec level_count(map()) :: non_neg_integer()
  def level_count(adj), do: parallel_levels(adj) |> length()

  defp compute_in_degree(adj) do
    base = Map.new(Map.keys(adj), &{&1, 0})

    Enum.reduce(adj, base, fn {_, deps}, acc ->
      Enum.reduce(deps, acc, fn dep, inner ->
        Map.update(inner, dep, 1, &(&1 + 1))
      end)
    end)
  end

  defp topo_sort(_adj, _in_degree, [], result) do
    {:ok, Enum.reverse(result)}
  end

  defp topo_sort(adj, in_degree, [node | rest], result) do
    deps = Map.get(adj, node, [])

    new_in_degree =
      Enum.reduce(deps, in_degree, fn dep, acc ->
        Map.update!(acc, dep, &(&1 - 1))
      end)

    new_ready =
      deps
      |> Enum.filter(fn dep -> Map.get(new_in_degree, dep) == 0 end)
      |> Enum.sort()

    topo_sort(adj, new_in_degree, Enum.sort(rest ++ new_ready), [node | result])
  end

  defp build_levels(adj, in_degree, levels) do
    ready =
      in_degree |> Enum.filter(fn {_, d} -> d == 0 end) |> Enum.map(&elem(&1, 0)) |> Enum.sort()

    if ready == [] do
      Enum.reverse(levels)
    else
      new_in_degree =
        ready
        |> Enum.reduce(Map.drop(in_degree, ready), &decrement_deps(adj, &1, &2))

      build_levels(adj, new_in_degree, [ready | levels])
    end
  end

  defp decrement_deps(adj, node, in_degree) do
    adj
    |> Map.get(node, [])
    |> Enum.reduce(in_degree, fn dep, acc -> Map.update(acc, dep, 0, &max(&1 - 1, 0)) end)
  end
end