diff --git a/pkg/commit/parser.go b/pkg/commit/parser.go index fb67348..072412a 100644 --- a/pkg/commit/parser.go +++ b/pkg/commit/parser.go @@ -3,6 +3,7 @@ package commit import ( "bytes" "errors" + "fmt" "regexp" ) @@ -10,33 +11,64 @@ const ( cr = 13 lf = 10 crlf = "\r\n" + + typeMatch = `^[\w-]+$` + scopeMatch = `^[\w\$\.\/\-\* ]+$` ) var ( rHeader = regexp.MustCompile( - `^([\w\-]*)(?:\(([\w\$\.\/\-\* ]*)\))?(!)?\: (.*)$`, + `^([^\(\)]*?)(\((.*?)\))?(!)?\:\s+(.*)$`, ) rFooter = regexp.MustCompile( - `^([\w-]+)\s+(#.*)|([\w-]+|BREAKING CHANGE):\s\s*(.*)$`, + `^([\w-]+)\s+(#.*)|([\w-]+|BREAKING CHANGE):\s+(.*)$`, ) + rType = regexp.MustCompile(typeMatch) + rScope = regexp.MustCompile(scopeMatch) + + Err = errors.New("") + + ErrFormat = fmt.Errorf("%winvalid format", Err) + ErrMultiLineHeader = fmt.Errorf("%w: header has multiple lines", ErrFormat) + + ErrType = fmt.Errorf("%wtype", Err) + ErrTypeFormat = fmt.Errorf("%w must match: %s", ErrType, typeMatch) + ErrTypeMissing = fmt.Errorf("%w is missing", ErrType) + + ErrScope = fmt.Errorf("%wscope", Err) + ErrScopeFormat = fmt.Errorf("%w must match: %s", ErrScope, scopeMatch) ) func parseHeader(header []byte) (*Commit, error) { + commit := &Commit{} + if bytes.ContainsAny(header, crlf) { - return nil, errors.New("header cannot span multiple lines") + return commit, ErrMultiLineHeader } result := rHeader.FindSubmatch(header) if result == nil { - return &Commit{Subject: string(header)}, nil + commit = &Commit{Subject: string(header)} + } else { + commit = &Commit{ + Type: string(bytes.TrimSpace(result[1])), + Scope: string(bytes.TrimSpace(result[3])), + Subject: string(bytes.TrimSpace(result[5])), + IsBreaking: string(result[4]) == "!", + } } - return &Commit{ - Type: string(result[1]), - Scope: string(result[2]), - Subject: string(result[4]), - IsBreaking: (string(result[3]) == "!"), - }, nil + if commit.Type == "" { + return commit, ErrTypeMissing + } else if !rType.MatchString(commit.Type) { + return commit, ErrTypeFormat + } + + if len(commit.Scope) > 0 && !rScope.MatchString(commit.Scope) { + return commit, ErrScopeFormat + } + + return commit, nil } func footers(paragraph []byte) []*Footer { diff --git a/pkg/commit/parser_test.go b/pkg/commit/parser_test.go index 9d5200f..edc02cd 100644 --- a/pkg/commit/parser_test.go +++ b/pkg/commit/parser_test.go @@ -1,6 +1,7 @@ package commit import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -15,11 +16,22 @@ func Test_parseHeader(t *testing.T) { args args want *Commit errStr string + errIs []error }{ { - name: "non-convention commit", - args: args{header: []byte("add user sorting option")}, - want: &Commit{Subject: "add user sorting option"}, + name: "missing type", + args: args{header: []byte("add user sorting option")}, + want: &Commit{Subject: "add user sorting option"}, + errIs: []error{Err, ErrTypeMissing}, + }, + { + name: "missing type with scope", + args: args{ + header: []byte("(user): add user sorting option"), + }, + want: &Commit{Scope: "user", Subject: "add user sorting option"}, + errStr: `type is missing`, + errIs: []error{Err, ErrType, ErrTypeMissing}, }, { name: "type only", @@ -147,19 +159,104 @@ func Test_parseHeader(t *testing.T) { Subject: "add user sorting option", }, }, + { + name: "excess whitespace in type with scope", + args: args{ + header: []byte(" feat (user sort): add user sorting option"), + }, + want: &Commit{ + Type: "feat", + Scope: "user sort", + Subject: "add user sorting option", + }, + }, + { + name: "excess whitespace in scope", + args: args{ + header: []byte("feat( user sort ): add user sorting option"), + }, + want: &Commit{ + Type: "feat", + Scope: "user sort", + Subject: "add user sorting option", + }, + }, + { + name: "excess whitespace in subject", + args: args{ + header: []byte("feat(user): add user sorting option "), + }, + want: &Commit{ + Type: "feat", + Scope: "user", + Subject: "add user sorting option", + }, + }, + { + name: "empty scope", + args: args{ + header: []byte("feat(): add user sorting option"), + }, + want: &Commit{ + Type: "feat", + Subject: "add user sorting option", + }, + }, { name: "multi-line header (LF)", args: args{ - header: []byte("feat(user)!: add usersorting\noption"), + header: []byte("feat(user)!: add user sorting\nnoption"), }, - errStr: "header cannot span multiple lines", + want: &Commit{}, + errStr: "invalid format: header has multiple lines", + errIs: []error{ErrFormat, ErrMultiLineHeader}, }, { name: "multi-line header (CR)", args: args{ - header: []byte("feat(user)!: add usersorting\roption"), + header: []byte("feat(user)!: add user sorting\roption"), }, - errStr: "header cannot span multiple lines", + want: &Commit{}, + errStr: "invalid format: header has multiple lines", + errIs: []error{Err, ErrFormat, ErrMultiLineHeader}, + }, + { + name: "invalid type character", + args: args{ + header: []byte("feat/internal: add user sorting option"), + }, + want: &Commit{ + Type: "feat/internal", + Subject: "add user sorting option", + }, + errStr: `type must match: ^[\w-]+$`, + errIs: []error{Err, ErrType, ErrTypeFormat}, + }, + { + name: "invalid type character with scope", + args: args{ + header: []byte("feat/internal(user): add user sorting option"), + }, + want: &Commit{ + Type: "feat/internal", + Scope: "user", + Subject: "add user sorting option", + }, + errStr: `type must match: ^[\w-]+$`, + errIs: []error{Err, ErrType, ErrTypeFormat}, + }, + { + name: "invalid scope character", + args: args{ + header: []byte("feat(user#sort): add user sorting option"), + }, + want: &Commit{ + Type: "feat", + Scope: "user#sort", + Subject: "add user sorting option", + }, + errStr: `scope must match: ^[\w\$\.\/\-\* ]+$`, + errIs: []error{Err, ErrScope, ErrScopeFormat}, }, } for _, tt := range tests { @@ -167,10 +264,20 @@ func Test_parseHeader(t *testing.T) { got, err := parseHeader(tt.args.header) if tt.errStr != "" { - assert.Error(t, err, tt.errStr) - } else { - assert.Equal(t, tt.want, got) + assert.EqualError(t, err, tt.errStr) } + + if len(tt.errIs) > 0 { + for _, errIs := range tt.errIs { + assert.True(t, errors.Is(err, errIs)) + } + } + + if len(tt.errIs) == 0 && tt.errStr == "" { + assert.NoError(t, err) + } + + assert.Equal(t, tt.want, got) }) } }