Skip to main content

lib/npm_semver.ex

defmodule NPMSemver do
  @moduledoc """
  npm-compatible semantic versioning.

  Parses and matches version ranges using npm's semver syntax:
  `^1.2.3`, `~1.2.3`, `>=1.0.0 <2.0.0`, `1.x`, `1.0.0 - 2.0.0`, `||` unions.

  ## Examples

      iex> NPMSemver.matches?("1.2.3", "^1.0.0")
      true

      iex> NPMSemver.matches?("2.0.0", "^1.0.0")
      false

      iex> NPMSemver.matches?("1.5.0", ">=1.2.3 <2.0.0")
      true

      iex> NPMSemver.matches?("1.2.4", "~1.2.3")
      true

      iex> NPMSemver.matches?("2.1.3", "2.x.x")
      true

  Test fixtures ported from [node-semver](https://github.com/npm/node-semver).
  """

  alias HexSolver.Constraint
  alias NPMSemver.{Range, Version}

  @doc """
  Check if a version satisfies a range.

      iex> NPMSemver.matches?("1.8.1", "^1.2.3")
      true

      iex> NPMSemver.matches?("0.1.2", "^0.1")
      true
  """
  @spec matches?(String.t(), String.t(), keyword()) :: boolean()
  def matches?(version_string, range_string, opts \\ []) do
    with {:ok, version} <- Version.parse(version_string, opts),
         {:ok, range} <- Range.parse(range_string, opts) do
      Range.satisfies?(range, version, opts)
    else
      _ -> false
    end
  end

  @doc """
  Parse a version string into a `NPMSemver.Version` struct.

      iex> {:ok, v} = NPMSemver.parse_version("1.2.3-beta.1")
      iex> {v.major, v.minor, v.patch, v.pre}
      {1, 2, 3, ["beta", 1]}
  """
  @spec parse_version(String.t(), keyword()) :: {:ok, Version.t()} | :error
  def parse_version(string, opts \\ []) do
    Version.parse(string, opts)
  end

  @doc """
  Parse a range string into a `NPMSemver.Range` struct.

      iex> {:ok, _range} = NPMSemver.parse_range("^1.2.3")
  """
  @spec parse_range(String.t(), keyword()) :: {:ok, Range.t()} | :error
  def parse_range(string, opts \\ []) do
    Range.parse(string, opts)
  end

  @doc """
  Find the highest version in a list that satisfies the range.

      iex> NPMSemver.max_satisfying(["1.0.0", "1.5.0", "2.0.0"], "^1.0.0")
      "1.5.0"

      iex> NPMSemver.max_satisfying(["0.1.0", "0.2.0"], "^1.0.0")
      nil
  """
  @spec max_satisfying([String.t()], String.t(), keyword()) :: String.t() | nil
  def max_satisfying(versions, range_string, opts \\ []) do
    case Range.parse(range_string, opts) do
      {:ok, range} -> do_max_satisfying(versions, range, opts)
      _ -> nil
    end
  end

  defp do_max_satisfying(versions, range, opts) do
    versions
    |> Enum.filter(&version_satisfies?(&1, range, opts))
    |> Enum.sort(fn a, b ->
      {:ok, va} = Version.parse(a, opts)
      {:ok, vb} = Version.parse(b, opts)
      Version.compare(va, vb) == :gt
    end)
    |> List.first()
  end

  defp version_satisfies?(v, range, opts) do
    case Version.parse(v, opts) do
      {:ok, ver} -> Range.satisfies?(range, ver, opts)
      _ -> false
    end
  end

  @doc """
  Convert an npm range string to a `HexSolver` constraint.

  Returns an opaque constraint value that can be used with `HexSolver.run/4`
  and returned from `HexSolver.Registry` callbacks.

      iex> {:ok, _constraint} = NPMSemver.to_hex_constraint("^1.2.3")
  """
  @spec to_hex_constraint(String.t(), keyword()) :: {:ok, HexSolver.constraint()} | :error
  def to_hex_constraint(range_string, opts \\ []) do
    case Range.parse(range_string, opts) do
      {:ok, range} -> to_hex_constraint_from_range(range)
      :error -> :error
    end
  end

  @doc """
  Convert an npm range string to a `hex_solver`-compatible Elixir requirement string.

      iex> NPMSemver.to_elixir_requirement("^1.2.3")
      {:ok, ">= 1.2.3 and < 2.0.0-0"}

      iex> NPMSemver.to_elixir_requirement("~1.2.3")
      {:ok, ">= 1.2.3 and < 1.3.0-0"}

      iex> NPMSemver.to_elixir_requirement(">=1.0.0 <2.0.0 || >=3.0.0")
      {:ok, ">= 1.0.0 and < 2.0.0 or >= 3.0.0"}
  """
  @spec to_elixir_requirement(String.t(), keyword()) :: {:ok, String.t()} | :error
  def to_elixir_requirement(range_string, opts \\ []) do
    case Range.parse(range_string, opts) do
      {:ok, range} -> {:ok, Range.to_elixir_string(range)}
      :error -> :error
    end
  end

  defp to_hex_constraint_from_range(%Range{sets: sets}) do
    Enum.reduce_while(sets, {:ok, nil}, fn comparators, {:ok, acc} ->
      case to_hex_constraint_from_set(comparators) do
        {:ok, constraint} ->
          next = if acc, do: Constraint.union(acc, constraint), else: constraint
          {:cont, {:ok, next}}

        :error ->
          {:halt, :error}
      end
    end)
    |> case do
      {:ok, nil} -> any_constraint()
      {:ok, constraint} -> {:ok, constraint}
      :error -> :error
    end
  end

  defp to_hex_constraint_from_set([]), do: any_constraint()

  defp to_hex_constraint_from_set([comparator | rest]) do
    with {:ok, first} <- to_hex_constraint_from_comparator(comparator) do
      Enum.reduce_while(rest, {:ok, first}, fn comparator, {:ok, acc} ->
        case to_hex_constraint_from_comparator(comparator) do
          {:ok, constraint} -> {:cont, {:ok, Constraint.intersect(acc, constraint)}}
          :error -> {:halt, :error}
        end
      end)
    end
  end

  defp to_hex_constraint_from_comparator(comparator) do
    comparator
    |> format_comparator()
    |> HexSolver.parse_constraint()
  end

  defp any_constraint, do: HexSolver.parse_constraint(">= 0.0.0")

  defp format_comparator({op, version}) do
    "#{op_to_string(op)} #{version}"
  end

  defp op_to_string(:gte), do: ">="
  defp op_to_string(:gt), do: ">"
  defp op_to_string(:lte), do: "<="
  defp op_to_string(:lt), do: "<"
  defp op_to_string(:eq), do: "=="
end