Skip to main content

lib/npm/node_modules.ex

defmodule NPM.NodeModules do
  @moduledoc """
  Inspect and query the `node_modules` directory.

  Provides functions to enumerate installed packages, compare
  against the lockfile, and detect anomalies.
  """

  @doc """
  List all installed packages in `node_modules`.

  Returns a sorted list of package names (including scoped).
  """
  @spec installed(String.t()) :: [String.t()]
  def installed(dir \\ "node_modules") do
    dir
    |> list_entries()
    |> Enum.reject(&String.starts_with?(&1, "."))
    |> Enum.flat_map(&expand_scope(dir, &1))
    |> Enum.sort()
  end

  @doc """
  Get the installed version of a package by reading its `package.json`.
  """
  @spec version(String.t(), String.t()) :: String.t() | nil
  def version(name, dir \\ "node_modules") do
    path = Path.join([dir, name, "package.json"])

    case File.read(path) do
      {:ok, content} -> content |> NPM.JSON.decode!() |> Map.get("version")
      {:error, _} -> nil
    end
  end

  @doc """
  Compare installed packages against the lockfile.

  Returns `{missing, extra}` where:
  - `missing` — packages in lockfile but not in `node_modules`
  - `extra` — packages in `node_modules` but not in lockfile
  """
  @spec diff(%{String.t() => term()}, String.t()) :: {[String.t()], [String.t()]}
  def diff(lockfile, dir \\ "node_modules") do
    locked = MapSet.new(Map.keys(lockfile))
    on_disk = MapSet.new(installed(dir))

    missing = MapSet.difference(locked, on_disk) |> MapSet.to_list() |> Enum.sort()
    extra = MapSet.difference(on_disk, locked) |> MapSet.to_list() |> Enum.sort()

    {missing, extra}
  end

  @doc """
  Get the total disk size of `node_modules` in bytes.
  """
  @spec disk_size(String.t()) :: non_neg_integer()
  def disk_size(dir \\ "node_modules") do
    if File.dir?(dir) do
      dir |> Path.join("**/*") |> Path.wildcard(match_dot: true) |> sum_file_sizes()
    else
      0
    end
  end

  defp sum_file_sizes(paths) do
    Enum.reduce(paths, 0, fn path, acc ->
      case File.stat(path) do
        {:ok, %{size: size, type: :regular}} -> acc + size
        _ -> acc
      end
    end)
  end

  @doc """
  Count the total number of files in `node_modules`.
  """
  @spec file_count(String.t()) :: non_neg_integer()
  def file_count(dir \\ "node_modules") do
    if File.dir?(dir) do
      dir
      |> Path.join("**/*")
      |> Path.wildcard(match_dot: true)
      |> Enum.count(&File.regular?/1)
    else
      0
    end
  end

  defp list_entries(dir) do
    case File.ls(dir) do
      {:ok, entries} -> entries
      {:error, _} -> []
    end
  end

  defp expand_scope(dir, "@" <> _ = scope) do
    dir |> Path.join(scope) |> list_entries() |> Enum.map(&"#{scope}/#{&1}")
  end

  defp expand_scope(_dir, name), do: [name]
end