defmodule NPMSemver.Version do
@moduledoc """
npm-compatible semver version parsing and comparison.
"""
import NimbleParsec
defstruct [:major, :minor, :patch, pre: [], build: []]
@type t :: %__MODULE__{
major: non_neg_integer(),
minor: non_neg_integer(),
patch: non_neg_integer(),
pre: [String.t() | integer()],
build: [String.t()]
}
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)
version =
integer(min: 1)
|> ignore(string("."))
|> integer(min: 1)
|> ignore(string("."))
|> integer(min: 1)
|> optional(pre_release)
|> optional(build_metadata)
|> eos()
defparsecp(:parse_version, version)
@doc """
Parse a version string.
In loose mode, accepts `v`-prefixed versions and pre-release tags
without the `-` separator (e.g., `1.2.3beta`).
"""
@spec parse(String.t(), keyword()) :: {:ok, t()} | :error
def parse(string, opts \\ []) do
string = String.trim(string)
loose = Keyword.get(opts, :loose, false)
string = if loose, do: String.trim_leading(string, "v"), else: string
string = if loose, do: normalize_loose_pre(string), else: string
case parse_version(string) do
{:ok, parts, "", _, _, _} -> {:ok, build_version(parts)}
_ -> :error
end
end
defp normalize_loose_pre(string) do
{core, rest} = split_at_build(string)
core =
case String.split(core, "-", parts: 2) do
[_base, _pre] ->
core
[base] ->
case Regex.run(~r/^(\d+\.\d+\.\d+)([a-zA-Z].*)$/, base) do
[_, ver, pre] -> "#{ver}-#{pre}"
nil -> base
end
end
case rest do
nil -> core
build -> "#{core}+#{build}"
end
end
defp split_at_build(string) do
case String.split(string, "+", parts: 2) do
[core, build] -> {core, build}
[core] -> {core, nil}
end
end
defp build_version(parts) do
{ints, rest} = Enum.split_while(parts, &is_integer/1)
[major, minor, patch] = ints
{pre, rest} =
case rest do
[{:pre, pre_parts} | r] -> {Enum.map(pre_parts, &coerce_pre_part/1), r}
_ -> {[], rest}
end
build =
case rest do
[{:build, build_parts}] -> build_parts
_ -> []
end
%__MODULE__{major: major, minor: minor, patch: patch, pre: pre, build: build}
end
defp coerce_pre_part(part) when is_binary(part) do
case Integer.parse(part) do
{n, ""} -> n
_ -> part
end
end
@doc "Compare two versions. Returns `:lt`, `:eq`, or `:gt`."
@spec compare(t(), t()) :: :lt | :eq | :gt
def compare(%__MODULE__{} = a, %__MODULE__{} = b) do
case compare_core(a, b) do
:eq ->
case {a.pre, b.pre} do
{[], []} -> :eq
{[], _} -> :gt
{_, []} -> :lt
{pre_a, pre_b} -> compare_pre(pre_a, pre_b)
end
other ->
other
end
end
defp compare_core(a, b) do
cond do
a.major != b.major -> if a.major > b.major, do: :gt, else: :lt
a.minor != b.minor -> if a.minor > b.minor, do: :gt, else: :lt
a.patch != b.patch -> if a.patch > b.patch, do: :gt, else: :lt
true -> :eq
end
end
defp compare_pre([], []), do: :eq
defp compare_pre([], _), do: :lt
defp compare_pre(_, []), do: :gt
defp compare_pre([a | rest_a], [b | rest_b]) do
case compare_pre_part(a, b) do
:eq -> compare_pre(rest_a, rest_b)
other -> other
end
end
defp compare_pre_part(a, b) when is_integer(a) and is_integer(b) do
cond do
a > b -> :gt
a < b -> :lt
true -> :eq
end
end
defp compare_pre_part(a, b) when is_integer(a) and is_binary(b), do: :lt
defp compare_pre_part(a, b) when is_binary(a) and is_integer(b), do: :gt
defp compare_pre_part(a, b) when is_binary(a) and is_binary(b) do
cond do
a > b -> :gt
a < b -> :lt
true -> :eq
end
end
@doc "Format a version struct back to a string."
@spec to_string(t()) :: String.t()
def to_string(%__MODULE__{} = v) do
core = "#{v.major}.#{v.minor}.#{v.patch}"
core =
case v.pre do
[] -> core
pre -> core <> "-" <> Enum.map_join(pre, ".", &Kernel.to_string/1)
end
case v.build do
[] -> core
build -> core <> "+" <> Enum.join(build, ".")
end
end
defimpl String.Chars do
def to_string(version), do: NPMSemver.Version.to_string(version)
end
end