Skip to main content

lib/npm_semver/range.ex

defmodule NPMSemver.Range do
  @moduledoc """
  npm-compatible semver range parsing and matching.

  A range is a union (`||`) of comparator sets.
  Each comparator set is an intersection (space-separated) of comparators.
  """

  import NimbleParsec

  alias NPMSemver.Version

  defstruct [:sets]

  @type comparator :: {:gte | :lte | :gt | :lt | :eq, Version.t()}
  @type comparator_set :: [comparator]
  @type t :: %__MODULE__{sets: [comparator_set]}

  # --- NimbleParsec tokenizer ---

  ws = ignore(ascii_string([?\s, ?\t], min: 1))
  optional_ws = optional(ws)

  number = integer(min: 1)
  wildcard = choice([string("x"), string("X"), string("*")]) |> replace(:x)
  nr = choice([number, wildcard])

  operator =
    choice([string(">="), string("<="), string("=="), string(">"), string("<"), string("=")])
    |> unwrap_and_tag(:op)

  identifier = ascii_string([?0..?9, ?a..?z, ?A..?Z, ?-], min: 1)

  pre_release =
    ignore(string("-"))
    |> concat(identifier |> repeat(ignore(string(".")) |> concat(identifier)))
    |> tag(:pre)

  build_metadata =
    ignore(string("+"))
    |> concat(identifier |> repeat(ignore(string(".")) |> concat(identifier)))
    |> tag(:build)

  partial =
    nr
    |> optional(ignore(string(".")) |> concat(nr))
    |> optional(ignore(string(".")) |> concat(nr))
    |> optional(pre_release)
    |> optional(build_metadata)
    |> tag(:partial)

  # v-prefix (loose)
  v_partial =
    optional(ignore(string("v")))
    |> concat(partial)

  hyphen = string("-") |> replace(:hyphen) |> unwrap_and_tag(:hyphen_sep)
  or_sep = string("||") |> replace(:or) |> unwrap_and_tag(:or)
  tilde = choice([string("~>"), string("~")]) |> replace(:tilde) |> unwrap_and_tag(:prefix)
  caret = string("^") |> replace(:caret) |> unwrap_and_tag(:prefix)

  token =
    choice([
      or_sep,
      tilde |> concat(optional_ws) |> concat(v_partial),
      caret |> concat(optional_ws) |> concat(v_partial),
      operator |> concat(optional_ws) |> concat(v_partial),
      hyphen,
      v_partial
    ])

  tokens =
    optional_ws
    |> concat(token)
    |> repeat(optional_ws |> concat(token))
    |> concat(optional_ws)
    |> eos()

  defparsecp(:tokenize, tokens)

  # --- Public API ---

  @doc "Parse an npm range string."
  @spec parse(String.t(), keyword()) :: {:ok, t()} | :error
  def parse(range_string, opts \\ []) do
    range_string = String.trim(range_string)

    if range_string in ["", "*", "||"] do
      {:ok, %__MODULE__{sets: [[]]}}
    else
      do_parse(range_string, opts)
    end
  end

  defp do_parse(range_string, opts) do
    include_pre = Keyword.get(opts, :include_prerelease, false)
    loose = Keyword.get(opts, :loose, false)
    range_string = if loose, do: normalize_loose_range(range_string), else: range_string

    with {:ok, tokens, "", _, _, _} <- tokenize(range_string),
         {:ok, sets} <- build_sets(tokens, include_pre) do
      {:ok, %__MODULE__{sets: sets}}
    else
      _ -> :error
    end
  end

  @doc "Check if a version satisfies a range."
  @spec satisfies?(t(), Version.t(), keyword()) :: boolean()
  def satisfies?(%__MODULE__{sets: sets}, %Version{} = version, opts \\ []) do
    include_pre = Keyword.get(opts, :include_prerelease, false)
    Enum.any?(sets, &set_satisfies?(&1, version, include_pre))
  end

  @doc "Convert range to Elixir/hex_solver requirement string."
  @spec to_elixir_string(t()) :: String.t()
  def to_elixir_string(%__MODULE__{sets: sets}) do
    Enum.map_join(sets, " or ", &format_comparator_set/1)
  end

  defp format_comparator_set(comparators) do
    Enum.map_join(comparators, " and ", &format_comparator/1)
  end

  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: "=="

  # --- Token → comparator set builder ---

  defp build_sets(tokens, include_pre) do
    sets =
      tokens
      |> split_on_or()
      |> Enum.map(fn set_tokens -> build_comparator_set(set_tokens, include_pre) end)

    {:ok, sets}
  end

  defp split_on_or(tokens) do
    tokens
    |> Enum.chunk_by(fn
      {:or, _} -> true
      _ -> false
    end)
    |> Enum.reject(fn
      [{:or, _} | _] -> true
      _ -> false
    end)
  end

  defp build_comparator_set(tokens, include_pre) do
    case find_hyphen(tokens) do
      {:hyphen, before, after_} ->
        from = extract_partial(before)
        to = extract_partial(after_)
        expand_hyphen(from, to, include_pre)

      :none ->
        build_comparators(tokens, include_pre)
    end
  end

  defp find_hyphen(tokens) do
    case Enum.split_while(tokens, fn
           {:hyphen_sep, _} -> false
           _ -> true
         end) do
      {before, [{:hyphen_sep, _} | after_]} when before != [] and after_ != [] ->
        {:hyphen, before, after_}

      _ ->
        :none
    end
  end

  defp build_comparators(tokens, include_pre) do
    tokens
    |> chunk_comparators()
    |> Enum.flat_map(fn group -> expand_token_group(group, include_pre) end)
  end

  defp chunk_comparators([]), do: []

  defp chunk_comparators([{:prefix, prefix}, {:partial, _} = p | rest]) do
    [{prefix, p} | chunk_comparators(rest)]
  end

  defp chunk_comparators([{:op, op_str}, {:partial, _} = p | rest]) do
    [{parse_op(op_str), p} | chunk_comparators(rest)]
  end

  defp chunk_comparators([{:partial, _} = p | rest]) do
    [{:bare, p} | chunk_comparators(rest)]
  end

  defp chunk_comparators([_ | rest]) do
    chunk_comparators(rest)
  end

  defp expand_token_group({:tilde, {:partial, parts}}, _include_pre) do
    expand_tilde(to_partial(parts))
  end

  defp expand_token_group({:caret, {:partial, parts}}, _include_pre) do
    expand_caret(to_partial(parts))
  end

  defp expand_token_group({:bare, {:partial, parts}}, include_pre) do
    expand_x_range(to_partial(parts), include_pre)
  end

  defp expand_token_group({op, {:partial, parts}}, include_pre) when is_atom(op) do
    expand_comparator(op, to_partial(parts), include_pre)
  end

  # --- Partial extraction ---

  defp extract_partial(tokens) do
    case tokens do
      [{:partial, parts}] -> to_partial(parts)
      _ -> {nil, nil, nil, nil}
    end
  end

  defp to_partial(parts) do
    {nums, extra} = Enum.split_while(parts, fn x -> is_integer(x) or x == :x end)

    pre =
      case Enum.find(extra, &match?({:pre, _}, &1)) do
        {:pre, p} -> p
        nil -> nil
      end

    nums =
      Enum.map(nums, fn
        :x -> nil
        n -> n
      end)

    case nums do
      [maj] -> {maj, nil, nil, pre}
      [maj, min] -> {maj, min, nil, pre}
      [maj, min, pat] -> {maj, min, pat, pre}
      _ -> {nil, nil, nil, nil}
    end
  end

  # --- Hyphen range ---

  defp expand_hyphen(from, to, include_pre) do
    from_v = pad(from, include_pre)
    to_comps = hyphen_upper(to)
    [{:gte, from_v} | to_comps]
  end

  defp pad({maj, min, pat, pre}, include_pre) do
    v = %Version{
      major: maj || 0,
      minor: min || 0,
      patch: pat || 0,
      pre: parse_pre_list(pre)
    }

    if include_pre and v.pre == [], do: %{v | pre: [0]}, else: v
  end

  defp hyphen_upper({nil, _, _, _}), do: []
  defp hyphen_upper({maj, nil, nil, _}), do: [{:lt, v(maj + 1, 0, 0, [0])}]
  defp hyphen_upper({maj, min, nil, _}), do: [{:lt, v(maj, min + 1, 0, [0])}]
  defp hyphen_upper({maj, min, pat, pre}), do: [{:lte, v(maj, min, pat, parse_pre_list(pre))}]

  # --- Tilde ---

  defp expand_tilde({nil, _, _, _}), do: []
  defp expand_tilde({maj, nil, nil, _}), do: [{:gte, v(maj, 0, 0)}, {:lt, v(maj + 1, 0, 0, [0])}]

  defp expand_tilde({maj, min, nil, _}),
    do: [{:gte, v(maj, min, 0)}, {:lt, v(maj, min + 1, 0, [0])}]

  defp expand_tilde({maj, min, pat, pre}) do
    [{:gte, v(maj, min, pat, parse_pre_list(pre))}, {:lt, v(maj, min + 1, 0, [0])}]
  end

  # --- Caret ---

  defp expand_caret({nil, _, _, _}), do: []
  defp expand_caret({0, nil, nil, _}), do: [{:lt, v(1, 0, 0, [0])}]

  defp expand_caret({0, 0, nil, p}),
    do: [{:gte, v(0, 0, 0, parse_pre_list(p))}, {:lt, v(0, 1, 0, [0])}]

  defp expand_caret({0, m, nil, p}),
    do: [{:gte, v(0, m, 0, parse_pre_list(p))}, {:lt, v(0, m + 1, 0, [0])}]

  defp expand_caret({0, 0, pat, p}),
    do: [{:gte, v(0, 0, pat, parse_pre_list(p))}, {:lt, v(0, 0, pat + 1, [0])}]

  defp expand_caret({0, m, pat, p}),
    do: [{:gte, v(0, m, pat, parse_pre_list(p))}, {:lt, v(0, m + 1, 0, [0])}]

  defp expand_caret({maj, nil, nil, p}) do
    [{:gte, v(maj, 0, 0, parse_pre_list(p))}, {:lt, v(maj + 1, 0, 0, [0])}]
  end

  defp expand_caret({maj, min, nil, p}) do
    [{:gte, v(maj, min, 0, parse_pre_list(p))}, {:lt, v(maj + 1, 0, 0, [0])}]
  end

  defp expand_caret({maj, min, pat, p}) do
    [{:gte, v(maj, min, pat, parse_pre_list(p))}, {:lt, v(maj + 1, 0, 0, [0])}]
  end

  # --- Comparator with operator ---

  defp expand_comparator(op, partial, include_pre)

  defp expand_comparator(op, {nil, _, _, _}, _) when op in [:gt, :lt] do
    [{:lt, v(0, 0, 0, [0])}]
  end

  defp expand_comparator(_op, {nil, _, _, _}, _), do: []

  defp expand_comparator(op, {maj, nil, nil, _}, include_pre) do
    case op do
      :gte -> [{:gte, v(maj, 0, 0)}]
      :gt -> [{:gte, v(maj + 1, 0, 0)}]
      :lte -> [{:lt, v(maj + 1, 0, 0, [0])}]
      :lt -> [{:lt, v(maj, 0, 0, [0])}]
      :eq -> expand_x_range({maj, nil, nil, nil}, include_pre)
    end
  end

  defp expand_comparator(op, {maj, min, nil, _}, include_pre) do
    case op do
      :gte -> [{:gte, v(maj, min, 0, if(include_pre, do: [0], else: []))}]
      :gt -> [{:gte, v(maj, min + 1, 0)}]
      :lte -> [{:lt, v(maj, min + 1, 0, [0])}]
      :lt -> [{:lt, v(maj, min, 0, [0])}]
      :eq -> expand_x_range({maj, min, nil, nil}, include_pre)
    end
  end

  defp expand_comparator(op, {maj, min, pat, pre}, _include_pre) do
    [{op, v(maj, min, pat, parse_pre_list(pre))}]
  end

  # --- X-range ---

  defp expand_x_range(partial, include_pre)

  defp expand_x_range({nil, _, _, _}, _), do: []

  defp expand_x_range({maj, nil, nil, _}, ip) do
    [{:gte, v(maj, 0, 0, if(ip, do: [0], else: []))}, {:lt, v(maj + 1, 0, 0, [0])}]
  end

  defp expand_x_range({maj, min, nil, _}, ip) do
    [{:gte, v(maj, min, 0, if(ip, do: [0], else: []))}, {:lt, v(maj, min + 1, 0, [0])}]
  end

  defp expand_x_range({maj, min, pat, pre}, _ip) do
    [{:eq, v(maj, min, pat, parse_pre_list(pre))}]
  end

  defp normalize_loose_range(string) do
    Regex.replace(~r/(\d+\.\d+\.\d+)([a-zA-Z])/, string, "\\g{1}-\\g{2}")
  end

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

  # --- Helpers ---

  defp v(maj, min, pat, pre \\ []) do
    %Version{major: maj, minor: min, patch: pat, pre: pre}
  end

  defp parse_pre_list(nil), do: []

  defp parse_pre_list(parts) when is_list(parts) do
    Enum.map(parts, fn part ->
      case Integer.parse(part) do
        {n, ""} -> n
        _ -> part
      end
    end)
  end

  # --- Satisfaction ---

  defp set_satisfies?([], version, include_pre) do
    include_pre or version.pre == []
  end

  defp set_satisfies?(comparators, version, include_pre) do
    if not include_pre and version.pre != [] do
      has_matching =
        Enum.any?(comparators, fn {_op, cv} ->
          cv.major == version.major and cv.minor == version.minor and
            cv.patch == version.patch and cv.pre != []
        end)

      has_matching and Enum.all?(comparators, &comparator_match?(&1, version))
    else
      Enum.all?(comparators, &comparator_match?(&1, version))
    end
  end

  defp comparator_match?({op, comp}, version) do
    cmp = Version.compare(version, comp)

    case op do
      :eq -> cmp == :eq
      :gte -> cmp in [:gt, :eq]
      :gt -> cmp == :gt
      :lte -> cmp in [:lt, :eq]
      :lt -> cmp == :lt
    end
  end
end