Skip to main content

src/version_bump@semver.erl

-module(version_bump@semver).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/version_bump/semver.gleam").
-export([release_type_rank/1, release_type_to_string/1, parse/1, to_string/1, compare/2, bump/2, effective_release_type/3, bump_with_prerelease/3, max/1]).
-export_type([release_type/0, version/0, versioning_mode/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " Semantic Versioning (SemVer 2.0.0) over the foundation `Version` type.\n"
    "\n"
    " This module is pure: parsing a string into a `Version`, rendering it back,\n"
    " comparing two versions per the SemVer precedence rules (build metadata\n"
    " ignored, prerelease identifiers compared field-by-field), and bumping a\n"
    " version by a `ReleaseType`.\n"
).

-type release_type() :: patch | minor | major.

-type version() :: {version,
        integer(),
        integer(),
        integer(),
        list(binary()),
        list(binary())}.

-type versioning_mode() :: initial_development | stable.

-file("src/version_bump/semver.gleam", 23).
-spec release_type_rank(release_type()) -> integer().
release_type_rank(T) ->
    case T of
        patch ->
            1;

        minor ->
            2;

        major ->
            3
    end.

-file("src/version_bump/semver.gleam", 31).
-spec release_type_to_string(release_type()) -> binary().
release_type_to_string(T) ->
    case T of
        patch ->
            <<"patch"/utf8>>;

        minor ->
            <<"minor"/utf8>>;

        major ->
            <<"major"/utf8>>
    end.

-file("src/version_bump/semver.gleam", 334).
-spec is_letter(binary()) -> boolean().
is_letter(C) ->
    case string:lowercase(C) of
        <<"a"/utf8>> ->
            true;

        <<"b"/utf8>> ->
            true;

        <<"c"/utf8>> ->
            true;

        <<"d"/utf8>> ->
            true;

        <<"e"/utf8>> ->
            true;

        <<"f"/utf8>> ->
            true;

        <<"g"/utf8>> ->
            true;

        <<"h"/utf8>> ->
            true;

        <<"i"/utf8>> ->
            true;

        <<"j"/utf8>> ->
            true;

        <<"k"/utf8>> ->
            true;

        <<"l"/utf8>> ->
            true;

        <<"m"/utf8>> ->
            true;

        <<"n"/utf8>> ->
            true;

        <<"o"/utf8>> ->
            true;

        <<"p"/utf8>> ->
            true;

        <<"q"/utf8>> ->
            true;

        <<"r"/utf8>> ->
            true;

        <<"s"/utf8>> ->
            true;

        <<"t"/utf8>> ->
            true;

        <<"u"/utf8>> ->
            true;

        <<"v"/utf8>> ->
            true;

        <<"w"/utf8>> ->
            true;

        <<"x"/utf8>> ->
            true;

        <<"y"/utf8>> ->
            true;

        <<"z"/utf8>> ->
            true;

        _ ->
            false
    end.

-file("src/version_bump/semver.gleam", 327).
-spec is_digit(binary()) -> boolean().
is_digit(C) ->
    case C of
        <<"0"/utf8>> ->
            true;

        <<"1"/utf8>> ->
            true;

        <<"2"/utf8>> ->
            true;

        <<"3"/utf8>> ->
            true;

        <<"4"/utf8>> ->
            true;

        <<"5"/utf8>> ->
            true;

        <<"6"/utf8>> ->
            true;

        <<"7"/utf8>> ->
            true;

        <<"8"/utf8>> ->
            true;

        <<"9"/utf8>> ->
            true;

        _ ->
            false
    end.

-file("src/version_bump/semver.gleam", 323).
-spec is_id_char(binary()) -> boolean().
is_id_char(C) ->
    (is_digit(C) orelse is_letter(C)) orelse (C =:= <<"-"/utf8>>).

-file("src/version_bump/semver.gleam", 317).
?DOC(
    " A string is a valid SemVer identifier character set: ASCII alphanumerics\n"
    " and hyphens only.\n"
).
-spec is_valid_alnum_id(binary()) -> boolean().
is_valid_alnum_id(S) ->
    _pipe = S,
    _pipe@1 = gleam@string:to_graphemes(_pipe),
    gleam@list:all(_pipe@1, fun is_id_char/1).

-file("src/version_bump/semver.gleam", 302).
?DOC(
    " Validate build identifiers: each must be non-empty and contain only\n"
    " [0-9A-Za-z-]. Leading zeros are allowed in build metadata.\n"
).
-spec validate_build(list(binary())) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
validate_build(Ids) ->
    gleam@list:try_each(Ids, fun(Id) -> case Id of
                <<""/utf8>> ->
                    {error, {version_error, <<"Empty build identifier"/utf8>>}};

                _ ->
                    case is_valid_alnum_id(Id) of
                        false ->
                            {error,
                                {version_error,
                                    <<"Invalid build identifier: "/utf8,
                                        Id/binary>>}};

                        true ->
                            {ok, nil}
                    end
            end end).

-file("src/version_bump/semver.gleam", 377).
-spec has_leading_zero(binary()) -> boolean().
has_leading_zero(S) ->
    (S /= <<"0"/utf8>>) andalso gleam_stdlib:string_starts_with(S, <<"0"/utf8>>).

-file("src/version_bump/semver.gleam", 367).
?DOC(" True when every character of a non-empty string is a digit.\n").
-spec is_numeric(binary()) -> boolean().
is_numeric(S) ->
    case S of
        <<""/utf8>> ->
            false;

        _ ->
            _pipe = S,
            _pipe@1 = gleam@string:to_graphemes(_pipe),
            gleam@list:all(_pipe@1, fun is_digit/1)
    end.

-file("src/version_bump/semver.gleam", 276).
?DOC(
    " Validate prerelease identifiers: each must be non-empty, contain only\n"
    " [0-9A-Za-z-], and numeric identifiers must not have leading zeros.\n"
).
-spec validate_prerelease(list(binary())) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
validate_prerelease(Ids) ->
    gleam@list:try_each(Ids, fun(Id) -> case Id of
                <<""/utf8>> ->
                    {error,
                        {version_error, <<"Empty prerelease identifier"/utf8>>}};

                _ ->
                    case is_valid_alnum_id(Id) of
                        false ->
                            {error,
                                {version_error,
                                    <<"Invalid prerelease identifier: "/utf8,
                                        Id/binary>>}};

                        true ->
                            case is_numeric(Id) of
                                true ->
                                    case has_leading_zero(Id) of
                                        true ->
                                            {error,
                                                {version_error,
                                                    <<"Numeric prerelease identifier has leading zero: "/utf8,
                                                        Id/binary>>}};

                                        false ->
                                            {ok, nil}
                                    end;

                                false ->
                                    {ok, nil}
                            end
                    end
            end end).

-file("src/version_bump/semver.gleam", 230).
?DOC(
    " Split a dot-separated section into identifiers, treating the empty string as\n"
    " \"no section\" (an empty list) rather than a single empty identifier.\n"
).
-spec split_dot_section(binary()) -> list(binary()).
split_dot_section(S) ->
    case S of
        <<""/utf8>> ->
            [];

        _ ->
            gleam@string:split(S, <<"."/utf8>>)
    end.

-file("src/version_bump/semver.gleam", 258).
?DOC(
    " Parse a non-negative integer that has no leading zeros (per SemVer the core\n"
    " and numeric prerelease identifiers must not have leading zeros). `0` itself\n"
    " is allowed.\n"
).
-spec parse_numeric_id(binary()) -> {ok, integer()} | {error, nil}.
parse_numeric_id(S) ->
    case S of
        <<""/utf8>> ->
            {error, nil};

        <<"0"/utf8>> ->
            {ok, 0};

        _ ->
            case gleam_stdlib:string_starts_with(S, <<"0"/utf8>>) of
                true ->
                    {error, nil};

                false ->
                    case gleam_stdlib:parse_int(S) of
                        {ok, N} when N >= 0 ->
                            {ok, N};

                        _ ->
                            {error, nil}
                    end
            end
    end.

-file("src/version_bump/semver.gleam", 238).
?DOC(" Parse the `major.minor.patch` core into a triple of ints.\n").
-spec parse_core(binary()) -> {ok, {integer(), integer(), integer()}} |
    {error, version_bump@error:release_error()}.
parse_core(Core) ->
    case gleam@string:split(Core, <<"."/utf8>>) of
        [Maj, Min, Pat] ->
            case {parse_numeric_id(Maj),
                parse_numeric_id(Min),
                parse_numeric_id(Pat)} of
                {{ok, A}, {ok, B}, {ok, C}} ->
                    {ok, {A, B, C}};

                {_, _, _} ->
                    {error,
                        {version_error,
                            <<"Invalid version core, expected numeric major.minor.patch: "/utf8,
                                Core/binary>>}}
            end;

        _ ->
            {error,
                {version_error,
                    <<"Invalid version, expected major.minor.patch: "/utf8,
                        Core/binary>>}}
    end.

-file("src/version_bump/semver.gleam", 221).
?DOC(
    " Split a string into the part before the first occurrence of `sep` and the\n"
    " part after it. If `sep` is not present, the second element is the empty\n"
    " string. The separator itself is dropped.\n"
).
-spec split_once(binary(), binary()) -> {binary(), binary()}.
split_once(S, Sep) ->
    case gleam@string:split_once(S, Sep) of
        {ok, {Before, After}} ->
            {Before, After};

        {error, _} ->
            {S, <<""/utf8>>}
    end.

-file("src/version_bump/semver.gleam", 211).
-spec strip_leading_v(binary()) -> binary().
strip_leading_v(S) ->
    case gleam_stdlib:string_starts_with(S, <<"v"/utf8>>) orelse gleam_stdlib:string_starts_with(
        S,
        <<"V"/utf8>>
    ) of
        true ->
            gleam@string:drop_start(S, 1);

        false ->
            S
    end.

-file("src/version_bump/semver.gleam", 58).
?DOC(
    " Parse a SemVer string into a `Version`.\n"
    "\n"
    " Accepts an optional leading `v` (e.g. `v1.2.3`), an optional `-prerelease`\n"
    " section of dot-separated identifiers, and an optional `+build` metadata\n"
    " section of dot-separated identifiers. The order of the optional sections is\n"
    " `core[-prerelease][+build]` per the spec.\n"
    "\n"
    " Returns a `VersionError` when the string is not a valid version.\n"
).
-spec parse(binary()) -> {ok, version()} |
    {error, version_bump@error:release_error()}.
parse(S) ->
    Trimmed = gleam@string:trim(S),
    Without_v = strip_leading_v(Trimmed),
    {Core_and_pre, Build} = split_once(Without_v, <<"+"/utf8>>),
    {Core, Prerelease, Has_pre} = case gleam@string:split_once(
        Core_and_pre,
        <<"-"/utf8>>
    ) of
        {ok, {Before, After}} ->
            {Before, After, true};

        {error, _} ->
            {Core_and_pre, <<""/utf8>>, false}
    end,
    case parse_core(Core) of
        {error, E} ->
            {error, E};

        {ok, {Major, Minor, Patch}} ->
            Pre_ids = case Has_pre of
                true ->
                    gleam@string:split(Prerelease, <<"."/utf8>>);

                false ->
                    []
            end,
            Build_ids = split_dot_section(Build),
            case {validate_prerelease(Pre_ids), validate_build(Build_ids)} of
                {{ok, _}, {ok, _}} ->
                    {ok, {version, Major, Minor, Patch, Pre_ids, Build_ids}};

                {{error, E@1}, _} ->
                    {error, E@1};

                {_, {error, E@2}} ->
                    {error, E@2}
            end
    end.

-file("src/version_bump/semver.gleam", 99).
?DOC(" Render a `Version` back to its canonical SemVer string (no leading `v`).\n").
-spec to_string(version()) -> binary().
to_string(V) ->
    Core = <<<<<<<<(erlang:integer_to_binary(erlang:element(2, V)))/binary,
                    "."/utf8>>/binary,
                (erlang:integer_to_binary(erlang:element(3, V)))/binary>>/binary,
            "."/utf8>>/binary,
        (erlang:integer_to_binary(erlang:element(4, V)))/binary>>,
    With_pre = case erlang:element(5, V) of
        [] ->
            Core;

        Ids ->
            <<<<Core/binary, "-"/utf8>>/binary,
                (gleam@string:join(Ids, <<"."/utf8>>))/binary>>
    end,
    case erlang:element(6, V) of
        [] ->
            With_pre;

        Ids@1 ->
            <<<<With_pre/binary, "+"/utf8>>/binary,
                (gleam@string:join(Ids@1, <<"."/utf8>>))/binary>>
    end.

-file("src/version_bump/semver.gleam", 415).
?DOC(
    " Compare two individual prerelease identifiers. Numeric identifiers compare\n"
    " numerically and rank lower than alphanumeric identifiers.\n"
).
-spec compare_identifier(binary(), binary()) -> gleam@order:order().
compare_identifier(A, B) ->
    case {is_numeric(A), is_numeric(B)} of
        {true, true} ->
            case {gleam_stdlib:parse_int(A), gleam_stdlib:parse_int(B)} of
                {{ok, Na}, {ok, Nb}} ->
                    gleam@int:compare(Na, Nb);

                {_, _} ->
                    gleam@string:compare(A, B)
            end;

        {true, false} ->
            lt;

        {false, true} ->
            gt;

        {false, false} ->
            gleam@string:compare(A, B)
    end.

-file("src/version_bump/semver.gleam", 400).
?DOC(
    " Compare two non-empty prerelease identifier lists field-by-field. When all\n"
    " compared fields are equal, the list with MORE fields has higher precedence.\n"
).
-spec compare_pre_fields(list(binary()), list(binary())) -> gleam@order:order().
compare_pre_fields(A, B) ->
    case {A, B} of
        {[], []} ->
            eq;

        {[], _} ->
            lt;

        {_, []} ->
            gt;

        {[X | Xs], [Y | Ys]} ->
            case compare_identifier(X, Y) of
                eq ->
                    compare_pre_fields(Xs, Ys);

                Other ->
                    Other
            end
    end.

-file("src/version_bump/semver.gleam", 388).
?DOC(
    " Compare two prerelease identifier lists per SemVer precedence rules.\n"
    "\n"
    " An empty list means \"no prerelease\" (a normal release), which outranks any\n"
    " prerelease. This release-vs-prerelease distinction only applies at the top\n"
    " level; once both versions are known to have prerelease identifiers, the\n"
    " lists are compared field-by-field where a larger set of fields wins when all\n"
    " preceding fields are equal (see `compare_pre_fields`).\n"
).
-spec compare_prerelease(list(binary()), list(binary())) -> gleam@order:order().
compare_prerelease(A, B) ->
    case {A, B} of
        {[], []} ->
            eq;

        {[], _} ->
            gt;

        {_, []} ->
            lt;

        {_, _} ->
            compare_pre_fields(A, B)
    end.

-file("src/version_bump/semver.gleam", 123).
?DOC(
    " Compare two versions per SemVer 2.0.0 precedence.\n"
    "\n"
    " Core version (major, minor, patch) is compared numerically. Build metadata\n"
    " is ignored. A version WITH a prerelease has LOWER precedence than the same\n"
    " version WITHOUT one. Prerelease identifiers are compared field-by-field:\n"
    " numeric identifiers compare numerically and always rank below alphanumeric\n"
    " ones; a longer set of identifiers wins when all preceding fields are equal.\n"
).
-spec compare(version(), version()) -> gleam@order:order().
compare(A, B) ->
    case gleam@int:compare(erlang:element(2, A), erlang:element(2, B)) of
        eq ->
            case gleam@int:compare(erlang:element(3, A), erlang:element(3, B)) of
                eq ->
                    case gleam@int:compare(
                        erlang:element(4, A),
                        erlang:element(4, B)
                    ) of
                        eq ->
                            compare_prerelease(
                                erlang:element(5, A),
                                erlang:element(5, B)
                            );

                        Other ->
                            Other
                    end;

                Other@1 ->
                    Other@1
            end;

        Other@2 ->
            Other@2
    end.

-file("src/version_bump/semver.gleam", 143).
?DOC(
    " Bump a version by a release type.\n"
    "\n"
    " Clears any prerelease and build metadata. `Major` increments major and\n"
    " resets minor and patch to 0. `Minor` increments minor and resets patch to 0.\n"
    " `Patch` increments patch.\n"
).
-spec bump(version(), release_type()) -> version().
bump(V, T) ->
    case T of
        major ->
            {version, erlang:element(2, V) + 1, 0, 0, [], []};

        minor ->
            {version, erlang:element(2, V), erlang:element(3, V) + 1, 0, [], []};

        patch ->
            {version,
                erlang:element(2, V),
                erlang:element(3, V),
                erlang:element(4, V) + 1,
                [],
                []}
    end.

-file("src/version_bump/semver.gleam", 163).
?DOC(
    " The release type to actually apply, given the versioning mode. In\n"
    " `InitialDevelopment` while the major version is 0, a breaking change is\n"
    " downshifted to a *minor* bump so it stays in `0.x` instead of jumping to\n"
    " `1.0.0`; features and fixes are unaffected, and once major >= 1 the mode has\n"
    " no effect.\n"
).
-spec effective_release_type(version(), release_type(), versioning_mode()) -> release_type().
effective_release_type(Version, T, Mode) ->
    case Mode of
        stable ->
            T;

        initial_development ->
            case erlang:element(2, Version) =:= 0 of
                false ->
                    T;

                true ->
                    case T of
                        major ->
                            minor;

                        minor ->
                            minor;

                        patch ->
                            patch
                    end
            end
    end.

-file("src/version_bump/semver.gleam", 188).
?DOC(
    " Bump a version by a release type, attaching a prerelease identifier.\n"
    "\n"
    " The core version is bumped exactly as `bump` does, then a prerelease of\n"
    " `[id, \"1\"]` is attached, e.g. bumping `1.1.0` by `Minor` with `\"beta\"`\n"
    " yields `1.2.0-beta.1`.\n"
).
-spec bump_with_prerelease(version(), release_type(), binary()) -> version().
bump_with_prerelease(V, T, Id) ->
    Bumped = bump(V, T),
    {version,
        erlang:element(2, Bumped),
        erlang:element(3, Bumped),
        erlang:element(4, Bumped),
        [Id, <<"1"/utf8>>],
        erlang:element(6, Bumped)}.

-file("src/version_bump/semver.gleam", 194).
?DOC(" The greatest version in a list per `compare`, or `None` if the list is empty.\n").
-spec max(list(version())) -> gleam@option:option(version()).
max(Versions) ->
    case Versions of
        [] ->
            none;

        [First | Rest] ->
            {some,
                gleam@list:fold(
                    Rest,
                    First,
                    fun(Acc, V) -> case compare(V, Acc) of
                            gt ->
                                V;

                            _ ->
                                Acc
                        end end
                )}
    end.