Skip to main content

lib/npm/side_effects.ex

defmodule NPM.SideEffects do
  @moduledoc """
  Analyzes the `sideEffects` field from package.json.

  Used by bundlers (webpack, rollup, esbuild) for tree-shaking.
  """

  @doc """
  Extracts the sideEffects value.
  """
  @spec get(map()) :: boolean() | [String.t()] | nil
  def get(%{"sideEffects" => val}), do: val
  def get(_), do: nil

  @doc """
  Checks if the package is fully tree-shakeable (sideEffects: false).
  """
  @spec tree_shakeable?(map()) :: boolean()
  def tree_shakeable?(%{"sideEffects" => false}), do: true
  def tree_shakeable?(_), do: false

  @doc """
  Checks if the package declares any side effects.
  """
  @spec has_side_effects?(map()) :: boolean()
  def has_side_effects?(%{"sideEffects" => false}), do: false
  def has_side_effects?(%{"sideEffects" => []}), do: false
  def has_side_effects?(_), do: true

  @doc """
  Returns files with side effects (when sideEffects is an array).
  """
  @spec files_with_side_effects(map()) :: [String.t()]
  def files_with_side_effects(%{"sideEffects" => files}) when is_list(files), do: files
  def files_with_side_effects(_), do: []

  @doc """
  Checks if a specific file has side effects.
  """
  @spec file_has_side_effects?(String.t(), map()) :: boolean()
  def file_has_side_effects?(_file, %{"sideEffects" => false}), do: false

  def file_has_side_effects?(file, %{"sideEffects" => patterns}) when is_list(patterns) do
    Enum.any?(patterns, &file_matches?(&1, file))
  end

  def file_has_side_effects?(_, _), do: true

  @doc """
  Counts tree-shakeable packages.
  """
  @spec stats([map()]) :: map()
  def stats(packages) do
    shakeable = Enum.count(packages, &tree_shakeable?/1)
    with_array = Enum.count(packages, &is_list(get(&1)))

    %{
      total: length(packages),
      tree_shakeable: shakeable,
      partial: with_array,
      unknown: length(packages) - shakeable - with_array
    }
  end

  defp file_matches?(pattern, file) do
    if String.contains?(pattern, "*") do
      regex_str = pattern |> Regex.escape() |> String.replace("\\*", ".*")
      Regex.match?(~r/^#{regex_str}$/, file)
    else
      file == pattern
    end
  end
end