Skip to main content

lib/npm/install/prune.ex

defmodule NPM.Install.Prune do
  @moduledoc """
  Identifies and removes extraneous packages from node_modules.

  Compares the installed packages against the lockfile to find
  packages that are no longer needed.
  """

  @type prune_entry :: %{
          name: String.t(),
          version: String.t() | nil,
          path: String.t(),
          reason: :not_in_lockfile | :orphaned_scope
        }

  @doc """
  Finds extraneous packages in node_modules that aren't in the lockfile.
  """
  @spec find_extraneous(String.t(), map()) :: [prune_entry()]
  def find_extraneous(node_modules_dir, lockfile) do
    case File.ls(node_modules_dir) do
      {:ok, entries} ->
        locked_names = MapSet.new(Map.keys(lockfile))

        entries
        |> Enum.flat_map(&check_entry(node_modules_dir, &1, locked_names))
        |> Enum.sort_by(& &1.name)

      _ ->
        []
    end
  end

  @doc """
  Calculates the total disk size of extraneous packages.
  """
  @spec extraneous_size([prune_entry()]) :: non_neg_integer()
  def extraneous_size(entries) do
    Enum.reduce(entries, 0, fn entry, acc ->
      acc + dir_size(entry.path)
    end)
  end

  @doc """
  Performs a dry run, returning what would be removed.
  """
  @spec dry_run(String.t(), map()) :: %{
          to_remove: [prune_entry()],
          count: non_neg_integer()
        }
  def dry_run(node_modules_dir, lockfile) do
    entries = find_extraneous(node_modules_dir, lockfile)
    %{to_remove: entries, count: length(entries)}
  end

  @doc """
  Removes extraneous packages from node_modules.
  Returns the list of removed entries.
  """
  @spec prune!(String.t(), map()) :: [prune_entry()]
  def prune!(node_modules_dir, lockfile) do
    entries = find_extraneous(node_modules_dir, lockfile)

    Enum.each(entries, fn entry ->
      File.rm_rf!(entry.path)
    end)

    entries
  end

  defp check_entry(nm_dir, entry, locked_names) do
    if String.starts_with?(entry, "@") do
      check_scoped(nm_dir, entry, locked_names)
    else
      check_regular(nm_dir, entry, locked_names)
    end
  end

  defp check_regular(nm_dir, name, locked_names) do
    if MapSet.member?(locked_names, name) do
      []
    else
      path = Path.join(nm_dir, name)
      version = read_version(path)
      [%{name: name, version: version, path: path, reason: :not_in_lockfile}]
    end
  end

  defp check_scoped(nm_dir, scope, locked_names) do
    scope_dir = Path.join(nm_dir, scope)

    case File.ls(scope_dir) do
      {:ok, sub_entries} ->
        Enum.flat_map(sub_entries, &check_scoped_sub(scope_dir, scope, &1, locked_names))

      _ ->
        []
    end
  end

  defp check_scoped_sub(scope_dir, scope, sub, locked_names) do
    name = "#{scope}/#{sub}"

    if MapSet.member?(locked_names, name) do
      []
    else
      path = Path.join(scope_dir, sub)
      version = read_version(path)
      [%{name: name, version: version, path: path, reason: :not_in_lockfile}]
    end
  end

  defp read_version(pkg_dir) do
    pkg_json = Path.join(pkg_dir, "package.json")

    case File.read(pkg_json) do
      {:ok, content} -> NPM.JSON.decode!(content)["version"]
      _ -> nil
    end
  rescue
    _ -> nil
  end

  defp dir_size(path) do
    if File.dir?(path) do
      path |> File.ls!() |> Enum.reduce(0, &(entry_size(path, &1) + &2))
    else
      file_size(path)
    end
  rescue
    _ -> 0
  end

  defp entry_size(parent, name) do
    full = Path.join(parent, name)
    if File.dir?(full), do: dir_size(full), else: file_size(full)
  end

  defp file_size(path) do
    case File.stat(path) do
      {:ok, %{size: size}} -> size
      _ -> 0
    end
  end
end