diff --git a/buffer.go b/buffer.go index 5679b9a..c60446b 100644 --- a/buffer.go +++ b/buffer.go @@ -1,38 +1,5 @@ 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 // string or byte slice. This makes it easier to process a message for the // purposes of extracting detailed information, linting, and formatting. @@ -119,11 +86,11 @@ func NewBuffer(message []byte) *Buffer { lastLen++ } - // If last paragraph starts with a Convention Commit footer token, it is the - // foot section, otherwise it is part of the body. + // If last paragraph starts with a Conventional Commit footer token, it is + // the foot section, otherwise it is part of the body. if lastLen > 0 { line := buf.lines[buf.lastLine-lastLen+1] - if footerToken.Match(line.Content) { + if FooterToken.Match(line.Content) { buf.footLen = lastLen } } @@ -176,6 +143,15 @@ func (s *Buffer) Lines() Lines { 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 { if s.headLen == 0 { return 0 @@ -184,6 +160,12 @@ func (s *Buffer) LineCount() int { 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 // trailing whitespace lines. Leading whitespace on the first line which // contains non-whitespace characters is retained. It is only whole lines diff --git a/buffer_test.go b/buffer_test.go index 4b0aa9f..556f72b 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -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) { for _, tt := range bufferTestCases { if tt.bytes == nil { diff --git a/line.go b/line.go index e642da5..cbbbf0e 100644 --- a/line.go +++ b/line.go @@ -58,9 +58,9 @@ type Lines []*Line // basis. func NewLines(content []byte) Lines { r := Lines{} - cLen := len(content) + length := len(content) - if cLen == 0 { + if length == 0 { return r } @@ -68,13 +68,13 @@ func NewLines(content []byte) Lines { var breaks [][]int // Locate each line break within content. - for i := 0; i < cLen; i++ { + for i := 0; i < length; i++ { switch content[i] { case lf: breaks = append(breaks, []int{i, i + 1}) case cr: b := []int{i, i + 1} - if i+1 < cLen && content[i+1] == lf { + if i+1 < length && content[i+1] == lf { b[1]++ i++ } diff --git a/message.go b/message.go new file mode 100644 index 0000000..6c48bd8 --- /dev/null +++ b/message.go @@ -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 +} diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..64b0e39 --- /dev/null +++ b/message_test.go @@ -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) + }) + } +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..13bda34 --- /dev/null +++ b/parse.go @@ -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) +} diff --git a/parse_test.go b/parse_test.go new file mode 100644 index 0000000..13e2414 --- /dev/null +++ b/parse_test.go @@ -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) + }) + } +}