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