wip(parser): partly finished Message parser

This commit is contained in:
2021-08-15 21:30:58 +01:00
parent 5174ed35ca
commit bf44c4a648
7 changed files with 759 additions and 40 deletions

View File

@@ -1,38 +1,5 @@
package conventionalcommit package conventionalcommit
import (
"regexp"
)
// footerToken will match against all variations of Conventional Commit footer
// formats.
//
// Examples of valid footer tokens:
//
// Approved-by: John Carter
// ReviewdBy: Noctis
// Fixes #49
// Reverts #SOL-42
// BREAKING CHANGE: Flux capacitor no longer exists.
// BREAKING-CHANGE: Time will flow backwads
//
// Examples of invalid footer tokens:
//
// Approved-by:
// Approved-by:John Carter
// Approved by: John Carter
// ReviewdBy: Noctis
// Fixes#49
// Fixes #
// Fixes 49
// BREAKING CHANGE:Flux capacitor no longer exists.
// Breaking Change: Flux capacitor no longer exists.
// Breaking-Change: Time will flow backwads
//
var footerToken = regexp.MustCompile(
`^(?:([\w-]+)\s+(#.+)|([\w-]+|BREAKING[\s-]CHANGE):\s+(.+))$`,
)
// Buffer represents a commit message in a more structured form than a simple // Buffer represents a commit message in a more structured form than a simple
// string or byte slice. This makes it easier to process a message for the // string or byte slice. This makes it easier to process a message for the
// purposes of extracting detailed information, linting, and formatting. // purposes of extracting detailed information, linting, and formatting.
@@ -119,11 +86,11 @@ func NewBuffer(message []byte) *Buffer {
lastLen++ lastLen++
} }
// If last paragraph starts with a Convention Commit footer token, it is the // If last paragraph starts with a Conventional Commit footer token, it is
// foot section, otherwise it is part of the body. // the foot section, otherwise it is part of the body.
if lastLen > 0 { if lastLen > 0 {
line := buf.lines[buf.lastLine-lastLen+1] line := buf.lines[buf.lastLine-lastLen+1]
if footerToken.Match(line.Content) { if FooterToken.Match(line.Content) {
buf.footLen = lastLen buf.footLen = lastLen
} }
} }
@@ -176,6 +143,15 @@ func (s *Buffer) Lines() Lines {
return s.lines[s.firstLine : s.lastLine+1] return s.lines[s.firstLine : s.lastLine+1]
} }
// LinesRaw returns all lines of the buffer including any blank lines at the
// beginning and end of the buffer.
func (s *Buffer) LinesRaw() Lines {
return s.lines
}
// LineCount returns number of lines in the buffer after discarding blank lines
// from the beginning and end of the buffer. Effectively counting all lines from
// the first to the last line which contain any non-whitespace characters.
func (s *Buffer) LineCount() int { func (s *Buffer) LineCount() int {
if s.headLen == 0 { if s.headLen == 0 {
return 0 return 0
@@ -184,6 +160,12 @@ func (s *Buffer) LineCount() int {
return (s.lastLine + 1) - s.firstLine return (s.lastLine + 1) - s.firstLine
} }
// LineCountRaw returns the number of lines in the buffer including any blank
// lines at the beginning and end of the buffer.
func (s *Buffer) LineCountRaw() int {
return len(s.lines)
}
// Bytes renders the Buffer back into a byte slice, without any leading or // Bytes renders the Buffer back into a byte slice, without any leading or
// trailing whitespace lines. Leading whitespace on the first line which // trailing whitespace lines. Leading whitespace on the first line which
// contains non-whitespace characters is retained. It is only whole lines // contains non-whitespace characters is retained. It is only whole lines

View File

@@ -994,6 +994,55 @@ func BenchmarkBuffer_Lines(b *testing.B) {
} }
} }
func TestBuffer_LinesRaw(t *testing.T) {
for _, tt := range bufferTestCases {
t.Run(tt.name, func(t *testing.T) {
want := tt.wantBuffer.lines[0:]
got := tt.wantBuffer.LinesRaw()
assert.Equal(t, want, got)
})
}
}
func TestBuffer_LineCount(t *testing.T) {
for _, tt := range bufferTestCases {
t.Run(tt.name, func(t *testing.T) {
want := tt.wantLines[1]
got := tt.wantBuffer.LineCount()
assert.Equal(t, want, got)
})
}
}
func BenchmarkBuffer_LineCount(b *testing.B) {
for _, tt := range bufferTestCases {
if tt.bytes == nil {
continue
}
b.Run(tt.name, func(b *testing.B) {
for n := 0; n < b.N; n++ {
_ = tt.wantBuffer.LineCount()
}
})
}
}
func TestBuffer_LineCountRaw(t *testing.T) {
for _, tt := range bufferTestCases {
t.Run(tt.name, func(t *testing.T) {
want := len(tt.wantBuffer.lines)
got := tt.wantBuffer.LineCountRaw()
assert.Equal(t, want, got)
})
}
}
func TestBuffer_Bytes(t *testing.T) { func TestBuffer_Bytes(t *testing.T) {
for _, tt := range bufferTestCases { for _, tt := range bufferTestCases {
if tt.bytes == nil { if tt.bytes == nil {

View File

@@ -58,9 +58,9 @@ type Lines []*Line
// basis. // basis.
func NewLines(content []byte) Lines { func NewLines(content []byte) Lines {
r := Lines{} r := Lines{}
cLen := len(content) length := len(content)
if cLen == 0 { if length == 0 {
return r return r
} }
@@ -68,13 +68,13 @@ func NewLines(content []byte) Lines {
var breaks [][]int var breaks [][]int
// Locate each line break within content. // Locate each line break within content.
for i := 0; i < cLen; i++ { for i := 0; i < length; i++ {
switch content[i] { switch content[i] {
case lf: case lf:
breaks = append(breaks, []int{i, i + 1}) breaks = append(breaks, []int{i, i + 1})
case cr: case cr:
b := []int{i, i + 1} b := []int{i, i + 1}
if i+1 < cLen && content[i+1] == lf { if i+1 < length && content[i+1] == lf {
b[1]++ b[1]++
i++ i++
} }

178
message.go Normal file
View File

@@ -0,0 +1,178 @@
package conventionalcommit
import (
"errors"
"fmt"
"regexp"
"strings"
)
var (
Err = errors.New("conventionalcommit")
ErrEmptyMessage = fmt.Errorf("%w: empty message", Err)
)
// HeaderToken will match a Conventional Commit formatted subject line, to
// extract type, scope, breaking change (bool), and description.
//
// It is intentionally VERY forgiving so as to be able to extract the various
// parts even when things aren't quite right.
var HeaderToken = regexp.MustCompile(
`^([^\(\)\r\n]*?)(\((.*?)\)\s*)?(!)?(\s*\:)\s(.*)$`,
)
// FooterToken will match against all variations of Conventional Commit footer
// formats.
//
// Examples of valid footer tokens:
//
// Approved-by: John Carter
// ReviewdBy: Noctis
// Fixes #49
// Reverts #SOL-42
// BREAKING CHANGE: Flux capacitor no longer exists.
// BREAKING-CHANGE: Time will flow backwads
//
// Examples of invalid footer tokens:
//
// Approved-by:
// Approved-by:John Carter
// Approved by: John Carter
// ReviewdBy: Noctis
// Fixes#49
// Fixes #
// Fixes 49
// BREAKING CHANGE:Flux capacitor no longer exists.
// Breaking Change: Flux capacitor no longer exists.
// Breaking-Change: Time will flow backwads
//
var FooterToken = regexp.MustCompile(
`^([\w-]+|BREAKING[\s-]CHANGE)(?:\s*(:)\s+|\s+(#))(.+)$`,
)
// Message represents a Conventional Commit message in a structured way.
type Message struct {
// Type indicates what kind of a change the commit message describes.
Type string
// Scope indicates the context/component/area that the change affects.
Scope string
// Description is the primary description for the commit.
Description string
// Body is the main text body of the commit message. Effectively all text
// between the subject line, and any footers if present.
Body string
// Footers are all footers which are not references or breaking changes.
Footers []*Footer
// References are all footers defined with a reference style token, for
// example:
//
// Fixes #42
References []*Reference
// Breaking is set to true if the message subject included the "!" breaking
// change indicator.
Breaking bool
// BreakingChanges includes the descriptions from all BREAKING CHANGE
// footers.
BreakingChanges []string
}
func NewMessage(buf *Buffer) (*Message, error) {
msg := &Message{}
count := buf.LineCount()
if count == 0 {
return nil, ErrEmptyMessage
}
msg.Description = buf.Head().Join("\n")
if m := HeaderToken.FindStringSubmatch(msg.Description); len(m) > 0 {
msg.Type = strings.TrimSpace(m[1])
msg.Scope = strings.TrimSpace(m[3])
msg.Breaking = m[4] == "!"
msg.Description = m[6]
}
msg.Body = buf.Body().Join("\n")
if foot := buf.Foot(); len(foot) > 0 {
footers := parseFooters(foot)
for _, f := range footers {
name := string(f.name)
value := string(f.value)
switch {
case f.ref:
msg.References = append(msg.References, &Reference{
Name: name,
Value: value,
})
case name == "BREAKING CHANGE" || name == "BREAKING-CHANGE":
msg.BreakingChanges = append(msg.BreakingChanges, value)
default:
msg.Footers = append(msg.Footers, &Footer{
Name: name,
Value: value,
})
}
}
}
return msg, nil
}
func (s *Message) IsBreakingChange() bool {
return s.Breaking || len(s.BreakingChanges) > 0
}
func parseFooters(lines Lines) []*rawFooter {
var footers []*rawFooter
footer := &rawFooter{}
for _, line := range lines {
if m := FooterToken.FindSubmatch(line.Content); m != nil {
if len(footer.name) > 0 {
footers = append(footers, footer)
}
footer = &rawFooter{}
if len(m[3]) > 0 {
footer.ref = true
footer.value = []byte{hash}
}
footer.name = m[1]
footer.value = append(footer.value, m[4]...)
} else if len(footer.name) > 0 {
footer.value = append(footer.value, lf)
footer.value = append(footer.value, line.Content...)
}
}
if len(footer.name) > 0 {
footers = append(footers, footer)
}
return footers
}
type rawFooter struct {
name []byte
value []byte
ref bool
}
type Footer struct {
Name string
Value string
}
type Reference struct {
Name string
Value string
}

80
message_test.go Normal file
View File

@@ -0,0 +1,80 @@
package conventionalcommit
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMessage_IsBreakingChange(t *testing.T) {
type fields struct {
Breaking bool
BreakingChanges []string
}
tests := []struct {
name string
fields fields
want bool
}{
{
name: "false breaking flag, no change texts",
fields: fields{
Breaking: false,
BreakingChanges: []string{},
},
want: false,
},
{
name: "true breaking flag, no change texts",
fields: fields{
Breaking: true,
BreakingChanges: []string{},
},
want: true,
},
{
name: "false breaking flag, 1 change texts",
fields: fields{
Breaking: false,
BreakingChanges: []string{"be careful"},
},
want: true,
},
{
name: "true breaking flag, 1 change texts",
fields: fields{
Breaking: true,
BreakingChanges: []string{"be careful"},
},
want: true,
},
{
name: "false breaking flag, 3 change texts",
fields: fields{
Breaking: false,
BreakingChanges: []string{"be careful", "oops", "ouch"},
},
want: true,
},
{
name: "true breaking flag, 3 change texts",
fields: fields{
Breaking: true,
BreakingChanges: []string{"be careful", "oops", "ouch"},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := &Message{
Breaking: tt.fields.Breaking,
BreakingChanges: tt.fields.BreakingChanges,
}
got := msg.IsBreakingChange()
assert.Equal(t, tt.want, got)
})
}
}

9
parse.go Normal file
View File

@@ -0,0 +1,9 @@
package conventionalcommit
// Parse parses a conventional commit message and returns it as a *Message
// struct.
func Parse(message []byte) (*Message, error) {
buffer := NewBuffer(message)
return NewMessage(buffer)
}

421
parse_test.go Normal file
View File

@@ -0,0 +1,421 @@
package conventionalcommit
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParse(t *testing.T) {
tests := []struct {
name string
message []byte
want *Message
wantErr string
}{
{
name: "empty",
message: []byte{},
wantErr: "conventionalcommit: empty message",
},
{
name: "description only",
message: []byte("change a thing"),
want: &Message{
Description: "change a thing",
},
},
{
name: "description and body",
message: []byte(`change a thing
more stuff
and more`,
),
want: &Message{
Description: "change a thing",
Body: "more stuff\nand more",
},
},
{
name: "type and description",
message: []byte("feat: change a thing"),
want: &Message{
Type: "feat",
Description: "change a thing",
},
},
{
name: "type, description and body",
message: []byte(
"feat: change a thing\n\nmore stuff\nand more",
),
want: &Message{
Type: "feat",
Description: "change a thing",
Body: "more stuff\nand more",
},
},
{
name: "type, scope and description",
message: []byte("feat(token): change a thing"),
want: &Message{
Type: "feat",
Scope: "token",
Description: "change a thing",
},
},
{
name: "type, scope, description and body",
message: []byte(
`feat(token): change a thing
more stuff
and more`,
),
want: &Message{
Type: "feat",
Scope: "token",
Description: "change a thing",
Body: "more stuff\nand more",
},
},
{
name: "breaking change in subject line",
message: []byte(
`feat!: change a thing
more stuff
and more`,
),
want: &Message{
Type: "feat",
Description: "change a thing",
Body: "more stuff\nand more",
Breaking: true,
},
},
{
name: "breaking change in subject line with scope",
message: []byte(
`feat(token)!: change a thing
more stuff
and more`,
),
want: &Message{
Type: "feat",
Scope: "token",
Description: "change a thing",
Body: "more stuff\nand more",
Breaking: true,
},
},
{
name: "BREAKING CHANGE footer",
message: []byte(
`feat: change a thing
BREAKING CHANGE: will blow up
`,
),
want: &Message{
Type: "feat",
Description: "change a thing",
BreakingChanges: []string{"will blow up"},
},
},
{
name: "BREAKING-CHANGE footer",
message: []byte(
`feat(token): change a thing
BREAKING-CHANGE: maybe not
`,
),
want: &Message{
Type: "feat",
Scope: "token",
Description: "change a thing",
BreakingChanges: []string{"maybe not"},
},
},
{
name: "reference footer",
message: []byte(
`feat: change a thing
Fixes #349
`,
),
want: &Message{
Type: "feat",
Description: "change a thing",
References: []*Reference{
{Name: "Fixes", Value: "#349"},
},
},
},
{
name: "reference (alt) footer",
message: []byte(
`feat: change a thing
Reverts #SOL-934
`,
),
want: &Message{
Type: "feat",
Description: "change a thing",
References: []*Reference{
{Name: "Reverts", Value: "#SOL-934"},
},
},
},
{
name: "token footer",
message: []byte(
`feat: change a thing
Approved-by: John Carter
`,
),
want: &Message{
Type: "feat",
Description: "change a thing",
Footers: []*Footer{
{Name: "Approved-by", Value: "John Carter"},
},
},
},
{
name: "token (alt) footer",
message: []byte(
`feat: change a thing
ReviewedBy: Noctis
`,
),
want: &Message{
Type: "feat",
Description: "change a thing",
Footers: []*Footer{
{Name: "ReviewedBy", Value: "Noctis"},
},
},
},
{
name: "BREAKING CHANGE footer with body",
message: []byte(
`feat: change a thing
more stuff
and more
BREAKING CHANGE: will blow up
`,
),
want: &Message{
Type: "feat",
Description: "change a thing",
Body: "more stuff\nand more",
BreakingChanges: []string{"will blow up"},
},
},
{
name: "BREAKING-CHANGE footer with body",
message: []byte(
`feat(token): change a thing
more stuff
and more
BREAKING-CHANGE: maybe not
`,
),
want: &Message{
Type: "feat",
Scope: "token",
Description: "change a thing",
Body: "more stuff\nand more",
BreakingChanges: []string{"maybe not"},
},
},
{
name: "reference footer with body",
message: []byte(
`feat: change a thing
more stuff
and more
Fixes #349
`,
),
want: &Message{
Type: "feat",
Description: "change a thing",
Body: "more stuff\nand more",
References: []*Reference{
{Name: "Fixes", Value: "#349"},
},
},
},
{
name: "reference (alt) footer with body",
message: []byte(
`feat: change a thing
more stuff
and more
Reverts #SOL-934
`,
),
want: &Message{
Type: "feat",
Description: "change a thing",
Body: "more stuff\nand more",
References: []*Reference{
{Name: "Reverts", Value: "#SOL-934"},
},
},
},
{
name: "token footer with body",
message: []byte(
`feat: change a thing
more stuff
and more
Approved-by: John Carter
`,
),
want: &Message{
Type: "feat",
Description: "change a thing",
Body: "more stuff\nand more",
Footers: []*Footer{
{Name: "Approved-by", Value: "John Carter"},
},
},
},
{
name: "token (alt) footer with body",
message: []byte(
`feat: change a thing
more stuff
and more
ReviewedBy: Noctis
`,
),
want: &Message{
Type: "feat",
Description: "change a thing",
Body: "more stuff\nand more",
Footers: []*Footer{
{Name: "ReviewedBy", Value: "Noctis"},
},
},
},
{
name: "type, scope, description, body and footers",
message: []byte(
`feat(token): change a thing
more stuff
and more
BREAKING CHANGE: will blow up
BREAKING-CHANGE: maybe not
Fixes #349
Reverts #SOL-934
Approved-by: John Carter
ReviewedBy: Noctis
`,
),
want: &Message{
Type: "feat",
Scope: "token",
Description: "change a thing",
Body: "more stuff\nand more",
Footers: []*Footer{
{Name: "Approved-by", Value: "John Carter"},
{Name: "ReviewedBy", Value: "Noctis"},
},
References: []*Reference{
{Name: "Fixes", Value: "#349"},
{Name: "Reverts", Value: "#SOL-934"},
},
BreakingChanges: []string{"will blow up", "maybe not"},
},
},
{
name: "multi-line footers",
message: []byte(
`feat(token): change a thing
Some stuff
BREAKING CHANGE: Nam euismod tellus id erat. Cum sociis natoque penatibus
et magnis dis parturient montes, nascetur ridiculous mus.
Approved-by: John Carter
and Noctis
Fixes #SOL-349 and also
#SOL-9440
`,
),
want: &Message{
Type: "feat",
Scope: "token",
Description: "change a thing",
Body: "Some stuff",
Footers: []*Footer{
{Name: "Approved-by", Value: "John Carter\nand Noctis"},
},
References: []*Reference{
{Name: "Fixes", Value: "#SOL-349 and also\n#SOL-9440"},
},
BreakingChanges: []string{
`Nam euismod tellus id erat. Cum sociis natoque penatibus
et magnis dis parturient montes, nascetur ridiculous mus.`,
},
},
},
{
name: "indented footer",
message: []byte(
`feat(token): change a thing
Some stuff
Approved-by: John Carter
`,
),
want: &Message{
Type: "feat",
Scope: "token",
Description: "change a thing",
Body: "Some stuff\n\n Approved-by: John Carter",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.message)
if tt.wantErr != "" {
assert.EqualError(t, err, tt.wantErr)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.want, got)
})
}
}