Skip to main content

lib/npm/install_strategy.ex

defmodule NPM.InstallStrategy do
  @moduledoc """
  Determines and configures package installation strategy.

  npm supports hoisted (default), nested, and isolated install strategies
  configured via `.npmrc` or `--install-strategy` flag.
  """

  @strategies ~w(hoisted nested shallow linked)a

  @doc """
  Detects the install strategy from config.
  """
  @spec detect(map()) :: atom()
  def detect(config) when is_map(config) do
    case config["install-strategy"] || config["install_strategy"] do
      "nested" -> :nested
      "shallow" -> :shallow
      "linked" -> :linked
      _ -> :hoisted
    end
  end

  @doc """
  Returns all supported strategies.
  """
  @spec strategies :: [atom()]
  def strategies, do: @strategies

  @doc """
  Checks if a strategy is valid.
  """
  @spec valid?(atom()) :: boolean()
  def valid?(strategy), do: strategy in @strategies

  @doc """
  Describes a strategy.
  """
  @spec describe(atom()) :: String.t()
  def describe(:hoisted), do: "Hoist all dependencies to top-level node_modules (default)"
  def describe(:nested), do: "Install dependencies nested in package folders"
  def describe(:shallow), do: "Only install direct dependencies at top level"
  def describe(:linked), do: "Symlink packages instead of copying"
  def describe(_), do: "Unknown strategy"

  @doc """
  Returns recommended strategy for a project configuration.
  """
  @spec recommend(map()) :: atom()
  def recommend(pkg_data) do
    cond do
      has_many_workspaces?(pkg_data) -> :hoisted
      has_conflicting_versions?(pkg_data) -> :nested
      true -> :hoisted
    end
  end

  @doc """
  Returns the node_modules structure depth for a strategy.
  """
  @spec max_depth(atom()) :: non_neg_integer() | :infinity
  def max_depth(:hoisted), do: 1
  def max_depth(:nested), do: :infinity
  def max_depth(:shallow), do: 1
  def max_depth(:linked), do: 1
  def max_depth(_), do: 1

  defp has_many_workspaces?(%{"workspaces" => ws}) when is_list(ws), do: length(ws) > 3
  defp has_many_workspaces?(_), do: false

  defp has_conflicting_versions?(%{"dependencies" => deps, "devDependencies" => dev_deps})
       when is_map(deps) and is_map(dev_deps) do
    common = MapSet.intersection(MapSet.new(Map.keys(deps)), MapSet.new(Map.keys(dev_deps)))
    MapSet.size(common) > 0
  end

  defp has_conflicting_versions?(_), do: false
end