Skip to main content

lib/npm/package/spec.ex

defmodule NPM.Package.Spec do
  @moduledoc """
  Parse npm package specifiers into structured data.

  npm supports multiple specifier formats:

      lodash          # name only (latest)
      lodash@^4.0     # name with range
      @scope/pkg@1.0  # scoped with range
      npm:react@^18   # alias
      file:../local   # file reference
      git+https://... # git reference
      https://...tgz  # URL tarball
  """

  @type t :: %{
          raw: String.t(),
          name: String.t(),
          range: String.t() | nil,
          type: :registry | :alias | :file | :git | :url
        }

  @doc """
  Parse a package specifier string.
  """
  @spec parse(String.t()) :: t()
  def parse("npm:" <> _ = spec) do
    case NPM.Alias.parse(spec) do
      {:alias, name, range} ->
        %{raw: spec, name: name, range: range, type: :alias}

      {:normal, _} ->
        %{raw: spec, name: spec, range: nil, type: :registry}
    end
  end

  def parse("file:" <> _ = spec) do
    %{raw: spec, name: spec, range: nil, type: :file}
  end

  def parse("git+" <> _ = spec) do
    %{raw: spec, name: spec, range: nil, type: :git}
  end

  def parse("git://" <> _ = spec) do
    %{raw: spec, name: spec, range: nil, type: :git}
  end

  def parse("github:" <> _ = spec) do
    %{raw: spec, name: spec, range: nil, type: :git}
  end

  def parse("http://" <> _ = spec) do
    %{raw: spec, name: spec, range: nil, type: :url}
  end

  def parse("https://" <> _ = spec) do
    %{raw: spec, name: spec, range: nil, type: :url}
  end

  def parse("@" <> _ = spec) do
    case String.split(spec, "@", parts: 3) do
      ["", scope_name, range] ->
        %{raw: spec, name: "@#{scope_name}", range: range, type: :registry}

      _ ->
        %{raw: spec, name: spec, range: nil, type: :registry}
    end
  end

  def parse(spec) do
    case String.split(spec, "@", parts: 2) do
      [name, range] when name != "" ->
        %{raw: spec, name: name, range: range, type: :registry}

      _ ->
        %{raw: spec, name: spec, range: nil, type: :registry}
    end
  end

  @doc """
  Check if a specifier targets the registry.
  """
  @spec registry?(t()) :: boolean()
  def registry?(%{type: :registry}), do: true
  def registry?(_), do: false

  @doc """
  Format a spec back to a string like `name@range`.
  """
  @spec to_string(t()) :: String.t()
  def to_string(%{name: name, range: nil}), do: name
  def to_string(%{name: name, range: range}), do: "#{name}@#{range}"
end