-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 `#` 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 \"\"` with a body containing\n" " `This reverts commit .`. 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( <<<
>/binary, Body/binary>> ), {conventional_commit, Commit, Type_, Scope, Subject, Breaking, Notes, References, Is_merge, Revert}.