-module(version_bump@commit_parser).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/version_bump/commit_parser.gleam").
-export([parse/1]).
-export_type([commit/0, commit_note/0, conventional_commit/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(
" Parse a raw foundation `Commit` into a `ConventionalCommit` following the\n"
" Conventional Commits / Angular preset.\n"
"\n"
" The parser is intentionally PURE: it derives everything it needs from the\n"
" commit's `subject` (the header line) and `body`. It never touches git or the\n"
" network. Regular expressions follow the shapes used by\n"
" `conventional-changelog-angular` and `@semantic-release/commit-analyzer`.\n"
).
-type commit() :: {commit,
binary(),
binary(),
binary(),
binary(),
binary(),
binary(),
binary()}.
-type commit_note() :: {commit_note, binary(), binary()}.
-type conventional_commit() :: {conventional_commit,
commit(),
gleam@option:option(binary()),
gleam@option:option(binary()),
binary(),
boolean(),
list(commit_note()),
list(binary()),
boolean(),
boolean()}.
-file("src/version_bump/commit_parser.gleam", 276).
?DOC(" De-duplicate a list keeping first-seen order.\n").
-spec dedupe(list(binary())) -> list(binary()).
dedupe(Items) ->
_pipe = Items,
_pipe@1 = gleam@list:fold(
_pipe,
[],
fun(Acc, Item) -> case gleam@list:contains(Acc, Item) of
true ->
Acc;
false ->
[Item | Acc]
end end
),
lists:reverse(_pipe@1).
-file("src/version_bump/commit_parser.gleam", 266).
-spec nth(list(KDX), integer()) -> gleam@option:option(KDX).
nth(Items, Index) ->
case {Items, Index} of
{[], _} ->
none;
{[First | _], 0} ->
{some, First};
{[_ | Rest], N} when N > 0 ->
nth(Rest, N - 1);
{_, _} ->
none
end.
-file("src/version_bump/commit_parser.gleam", 250).
?DOC(
" Read the nth submatch (0-indexed) and return it only when it is `Some` and\n"
" non-empty after trimming.\n"
).
-spec submatch_nonempty(list(gleam@option:option(binary())), integer()) -> gleam@option:option(binary()).
submatch_nonempty(Submatches, Index) ->
case nth(Submatches, Index) of
{some, {some, S}} ->
Trimmed = gleam@string:trim(S),
case Trimmed of
<<""/utf8>> ->
none;
_ ->
{some, Trimmed}
end;
_ ->
none
end.
-file("src/version_bump/commit_parser.gleam", 235).
?DOC(
" A regexp that matches nothing — used only as an unreachable fallback for the\n"
" statically-known patterns above. `(?!)` is a negative lookahead on the empty\n"
" string, which can never succeed. If even that fails to compile (it won't on\n"
" the Erlang target) we degrade to the empty pattern, which always compiles.\n"
).
-spec never_match() -> gleam@regexp:regexp().
never_match() ->
case gleam@regexp:from_string(<<"(?!)"/utf8>>) of
{ok, Re} ->
Re;
{error, _} ->
case gleam@regexp:from_string(<<""/utf8>>) of
{ok, Re@1} ->
Re@1;
{error, _} ->
never_match()
end
end.
-file("src/version_bump/commit_parser.gleam", 206).
?DOC(
" Safe regexp construction. On a (developer error) compile failure we fall back\n"
" to a regexp that never matches, so callers stay total without `let assert`.\n"
).
-spec build_regexp(binary()) -> gleam@regexp:regexp().
build_regexp(Pattern) ->
case gleam@regexp:from_string(Pattern) of
{ok, Re} ->
Re;
{error, _} ->
never_match()
end.
-file("src/version_bump/commit_parser.gleam", 198).
?DOC(
" Matches `#<digits>` optionally preceded by an owner/repo prefix\n"
" (`owner/repo#123`). Only the issue number is captured.\n"
).
-spec reference_regexp() -> gleam@regexp:regexp().
reference_regexp() ->
build_regexp(<<"(?:[\\w.-]+\\/[\\w.-]+)?#(\\d+)"/utf8>>).
-file("src/version_bump/commit_parser.gleam", 185).
?DOC(
" Collect issue references such as `#123` and `fixes #45` from the full commit\n"
" text. Each captured number is returned prefixed with `#`, de-duplicated in\n"
" first-seen order.\n"
).
-spec parse_references(binary()) -> list(binary()).
parse_references(Text) ->
_pipe = gleam@regexp:scan(reference_regexp(), Text),
_pipe@1 = gleam@list:filter_map(
_pipe,
fun(Match) -> case submatch_nonempty(erlang:element(3, Match), 0) of
{some, Num} ->
{ok, <<"#"/utf8, Num/binary>>};
none ->
{error, nil}
end end
),
dedupe(_pipe@1).
-file("src/version_bump/commit_parser.gleam", 223).
?DOC(
" Case-insensitive but single-line: `$` anchors at end-of-string, not at every\n"
" line end. Used for footers whose text may span multiple lines.\n"
).
-spec build_regexp_ci_singleline(binary()) -> gleam@regexp:regexp().
build_regexp_ci_singleline(Pattern) ->
Options = {options, true, false},
case gleam@regexp:compile(Pattern, Options) of
{ok, Re} ->
Re;
{error, _} ->
never_match()
end.
-file("src/version_bump/commit_parser.gleam", 174).
?DOC(
" Matches a `BREAKING CHANGE` or `BREAKING-CHANGE` footer (optionally followed\n"
" by `:` and/or whitespace) and captures the text up to the next blank line or\n"
" end of input. `[\\s\\S]` (rather than `.` with a dot-all flag) spans newlines so\n"
" multi-line notes are captured — and, unlike the inline `(?s)` flag, it\n"
" compiles on BOTH the Erlang and JavaScript targets (JS `RegExp` rejects a\n"
" leading `(?s)`). The `(?=\\n\\n|$)` lookahead stops at the next paragraph break.\n"
" Built WITHOUT the multi-line flag so the trailing `$` anchors at the real end\n"
" of the body rather than at every line end (which would truncate notes).\n"
).
-spec breaking_regexp() -> gleam@regexp:regexp().
breaking_regexp() ->
build_regexp_ci_singleline(
<<"breaking[ -]change(?:s)?:?\\s*([\\s\\S]+?)(?=\\n[ \\t]*\\n|$)"/utf8>>
).
-file("src/version_bump/commit_parser.gleam", 153).
?DOC(
" Extract `BREAKING CHANGE:` / `BREAKING-CHANGE:` notes from the body.\n"
"\n"
" Everything from the marker to the next blank line (or end of body) is the\n"
" note text. Multiple markers each produce a separate `CommitNote` whose title\n"
" is normalized to `BREAKING CHANGE`.\n"
).
-spec breaking_notes(binary()) -> list(commit_note()).
breaking_notes(Body) ->
_pipe = gleam@regexp:scan(breaking_regexp(), Body),
gleam@list:filter_map(
_pipe,
fun(Match) -> case submatch_nonempty(erlang:element(3, Match), 0) of
{some, Text} ->
{ok,
{commit_note,
<<"BREAKING CHANGE"/utf8>>,
gleam@string:trim(Text)}};
none ->
{error, nil}
end end
).
-file("src/version_bump/commit_parser.gleam", 113).
?DOC(
" `type(scope)!: subject`\n"
" group 1: type\n"
" group 2: scope (without parens)\n"
" group 3: optional `!`\n"
" group 4: subject\n"
).
-spec header_regexp() -> gleam@regexp:regexp().
header_regexp() ->
build_regexp(<<"^(\\w+)(?:\\(([^()]*)\\))?(!)?:\\s*(.*)$"/utf8>>).
-file("src/version_bump/commit_parser.gleam", 90).
?DOC(
" Parse the header line into `#(type, scope, breaking, subject)`.\n"
"\n"
" Matches `type(scope)!: subject` where `(scope)` and the trailing `!` are\n"
" optional. A leading/trailing whitespace-tolerant pattern is used so headers\n"
" like `feat: thing` and `fix(parser)!: thing` both parse. If the header has\n"
" no recognizable type prefix, returns `#(None, None, False, raw_header)`.\n"
).
-spec parse_header(binary()) -> {gleam@option:option(binary()),
gleam@option:option(binary()),
boolean(),
binary()}.
parse_header(Header) ->
case gleam@regexp:scan(header_regexp(), gleam@string:trim(Header)) of
[{match, _, Submatches} | _] ->
Type_ = submatch_nonempty(Submatches, 0),
Scope = submatch_nonempty(Submatches, 1),
Breaking = submatch_nonempty(Submatches, 2) =:= {some, <<"!"/utf8>>},
Subject = case submatch_nonempty(Submatches, 3) of
{some, S} ->
S;
none ->
gleam@string:trim(Header)
end,
{Type_, Scope, Breaking, Subject};
[] ->
{none, none, false, gleam@string:trim(Header)}
end.
-file("src/version_bump/commit_parser.gleam", 213).
-spec build_regexp_ci(binary()) -> gleam@regexp:regexp().
build_regexp_ci(Pattern) ->
Options = {options, true, true},
case gleam@regexp:compile(Pattern, Options) of
{ok, Re} ->
Re;
{error, _} ->
never_match()
end.
-file("src/version_bump/commit_parser.gleam", 142).
-spec reverts_body_regexp() -> gleam@regexp:regexp().
reverts_body_regexp() ->
build_regexp_ci(<<"this reverts commit\\s+[0-9a-f]+"/utf8>>).
-file("src/version_bump/commit_parser.gleam", 138).
-spec revert_header_regexp() -> gleam@regexp:regexp().
revert_header_regexp() ->
build_regexp_ci(<<"^revert:?\\s+\"?.+\"?"/utf8>>).
-file("src/version_bump/commit_parser.gleam", 133).
?DOC(
" A revert commit is `Revert \"<original subject>\"` with a body containing\n"
" `This reverts commit <hash>.`. We treat either the header form or the body\n"
" marker as sufficient to flag the commit as a revert (Angular preset relies\n"
" primarily on the header, but the body marker is a strong fallback).\n"
).
-spec parse_revert(binary(), binary()) -> boolean().
parse_revert(Header, Body) ->
gleam@regexp:check(revert_header_regexp(), gleam@string:trim(Header)) orelse gleam@regexp:check(
reverts_body_regexp(),
Body
).
-file("src/version_bump/commit_parser.gleam", 125).
-spec merge_regexp() -> gleam@regexp:regexp().
merge_regexp() ->
build_regexp_ci(<<"^merge\\b"/utf8>>).
-file("src/version_bump/commit_parser.gleam", 121).
?DOC(
" A merge commit header begins with `Merge ` (case-insensitive), e.g.\n"
" `Merge pull request #1 from ...` or `Merge branch 'x' into 'y'`.\n"
).
-spec is_merge_header(binary()) -> boolean().
is_merge_header(Header) ->
gleam@regexp:check(merge_regexp(), gleam@string:trim(Header)).
-file("src/version_bump/commit_parser.gleam", 55).
?DOC(
" Parse a `Commit` into a `ConventionalCommit`.\n"
"\n"
" The header (`commit.subject`) is matched against `type(scope)!: subject`.\n"
" When it doesn't match, `type_` and `scope` are `None` and the raw subject is\n"
" kept verbatim. Breaking changes are detected from a trailing `!` in the\n"
" header and from `BREAKING CHANGE:` / `BREAKING-CHANGE:` footers in the body,\n"
" either of which produces a `CommitNote`. References such as `#123` or\n"
" `fixes #123` are collected, and merge / revert commits are flagged.\n"
).
-spec parse(commit()) -> conventional_commit().
parse(Commit) ->
Header = erlang:element(4, Commit),
Body = erlang:element(5, Commit),
Is_merge = is_merge_header(Header),
Revert = parse_revert(Header, Body),
{Type_, Scope, Header_breaking, Subject} = parse_header(Header),
Notes = breaking_notes(Body),
Breaking = Header_breaking orelse not gleam@list:is_empty(Notes),
References = parse_references(
<<<<Header/binary, "\n"/utf8>>/binary, Body/binary>>
),
{conventional_commit,
Commit,
Type_,
Scope,
Subject,
Breaking,
Notes,
References,
Is_merge,
Revert}.