Skip to main content

lib/npm/why.ex

defmodule NPM.Why do
  @moduledoc """
  Explains why a package is installed by tracing through the dependency tree.

  Implements the `npm why` / `npm explain` functionality.
  """

  @type reason :: %{
          path: [String.t()],
          range: String.t() | nil,
          direct: boolean()
        }

  @doc """
  Explains why a package is in the lockfile.

  Returns a list of dependency paths that lead to the given package.
  Each path is a list of package names from root to the target.
  """
  @spec explain(String.t(), map(), map()) :: [reason()]
  def explain(target, lockfile, root_deps) do
    direct_reasons = direct_reasons(target, root_deps)
    transitive_reasons = transitive_reasons(target, lockfile, root_deps)
    direct_reasons ++ transitive_reasons
  end

  @doc """
  Returns a human-readable explanation string.
  """
  @spec format_reasons([reason()]) :: String.t()
  def format_reasons([]), do: "Package is not installed."

  def format_reasons(reasons) do
    Enum.map_join(reasons, "\n", &format_reason/1)
  end

  @doc """
  Checks if a package is a direct dependency.
  """
  @spec direct?(String.t(), map()) :: boolean()
  def direct?(name, root_deps) do
    Map.has_key?(root_deps, name)
  end

  @doc """
  Finds all packages that directly depend on the target.
  """
  @spec dependents(String.t(), map()) :: [String.t()]
  def dependents(target, lockfile) do
    lockfile
    |> Enum.filter(fn {_name, entry} ->
      Map.has_key?(entry.dependencies, target)
    end)
    |> Enum.map(&elem(&1, 0))
    |> Enum.sort()
  end

  defp direct_reasons(target, root_deps) do
    case Map.get(root_deps, target) do
      nil -> []
      range -> [%{path: [target], range: range, direct: true}]
    end
  end

  defp transitive_reasons(target, lockfile, root_deps) do
    lockfile
    |> Enum.filter(fn {_name, entry} ->
      Map.has_key?(entry.dependencies, target)
    end)
    |> Enum.flat_map(fn {parent, entry} ->
      range = entry.dependencies[target]

      parent_paths =
        if Map.has_key?(root_deps, parent) do
          [[parent, target]]
        else
          find_paths_to(parent, lockfile, root_deps, MapSet.new([target]))
          |> Enum.map(&(&1 ++ [target]))
        end

      Enum.map(parent_paths, fn path ->
        %{path: path, range: range, direct: false}
      end)
    end)
  end

  defp find_paths_to(target, lockfile, root_deps, visited) do
    if Map.has_key?(root_deps, target) do
      [[target]]
    else
      lockfile
      |> Enum.filter(fn {name, entry} ->
        Map.has_key?(entry.dependencies, target) and
          not MapSet.member?(visited, name)
      end)
      |> Enum.flat_map(fn {parent, _entry} ->
        find_paths_to(parent, lockfile, root_deps, MapSet.put(visited, parent))
        |> Enum.map(&(&1 ++ [target]))
      end)
    end
  end

  defp format_reason(%{path: path, range: range, direct: true}) do
    "#{hd(path)}@#{range} (direct dependency)"
  end

  defp format_reason(%{path: path, range: range}) do
    chain = Enum.join(path, " → ")
    last = path |> Enum.reverse() |> hd()
    "#{chain} (requires #{last}@#{range})"
  end
end