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