Skip to main content

lib/npm/scope.ex

defmodule NPM.Scope do
  @moduledoc """
  Parses and manages npm package scopes.

  Scopes are namespaces prefixed with `@` (e.g., `@babel/core`).
  They map to registries, organizations, and access controls.
  """

  @doc """
  Checks if a package name is scoped.
  """
  @spec scoped?(String.t()) :: boolean()
  def scoped?("@" <> rest), do: String.contains?(rest, "/")
  def scoped?(_), do: false

  @doc """
  Extracts the scope from a package name (without @).
  """
  @spec extract(String.t()) :: String.t() | nil
  def extract("@" <> rest) do
    case String.split(rest, "/", parts: 2) do
      [scope, _name] -> scope
      _ -> nil
    end
  end

  def extract(_), do: nil

  @doc """
  Extracts the bare package name (without scope).
  """
  @spec bare_name(String.t()) :: String.t()
  def bare_name("@" <> rest) do
    case String.split(rest, "/", parts: 2) do
      [_scope, name] -> name
      _ -> rest
    end
  end

  def bare_name(name), do: name

  @doc """
  Constructs a full scoped name from scope and package name.
  """
  @spec full_name(String.t(), String.t()) :: String.t()
  def full_name(scope, name), do: "@#{scope}/#{name}"

  @doc """
  Validates a scope name.

  Scopes must be lowercase, URL-safe, and not start with a dot or underscore.
  """
  @spec valid_scope?(String.t()) :: boolean()
  def valid_scope?(scope) do
    Regex.match?(~r/^[a-z][a-z0-9._-]*$/, scope)
  end

  @doc """
  Validates a full package name (scoped or unscoped).
  """
  @spec valid_name?(String.t()) :: boolean()
  def valid_name?(name) do
    if scoped?(name) do
      valid_scope?(extract(name)) and valid_bare_name?(bare_name(name))
    else
      valid_bare_name?(name)
    end
  end

  @doc """
  Lists all unique scopes found in a set of package names.
  """
  @spec unique_scopes([String.t()]) :: [String.t()]
  def unique_scopes(names) do
    names
    |> Enum.flat_map(fn name ->
      case extract(name) do
        nil -> []
        scope -> [scope]
      end
    end)
    |> Enum.uniq()
    |> Enum.sort()
  end

  @doc """
  Groups package names by scope (nil for unscoped).
  """
  @spec group_by_scope([String.t()]) :: %{(String.t() | nil) => [String.t()]}
  def group_by_scope(names) do
    Enum.group_by(names, &extract/1)
  end

  defp valid_bare_name?(name) do
    byte_size(name) > 0 and byte_size(name) <= 214 and
      Regex.match?(~r/^[a-z0-9][a-z0-9._-]*$/, name)
  end
end