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