Merge pull request #1 from romdo/add-rawmessage

feat(parser): implement RawMessage
This commit is contained in:
2021-08-09 21:06:35 +01:00
committed by GitHub
12 changed files with 2205 additions and 0 deletions

145
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,145 @@
---
name: CI
on: [push]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: golangci-lint
uses: golangci/golangci-lint-action@v2
with:
version: v1.41
env:
VERBOSE: "true"
tidy:
name: Tidy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: 1.15
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Check if mods are tidy
run: make check-tidy
benchmark:
name: Benchmarks
runs-on: ubuntu-latest
if: github.ref != 'refs/heads/main'
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: 1.15
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Run benchmarks
run: make bench | tee output.raw
- name: Fix benchmark names
run: >-
perl -pe 's/^(Benchmark.+?)\/(\S+)(-\d+)(\s+)/\1__\2\4/' output.raw |
tr '-' '_' | tee output.txt
- name: Announce benchmark result
uses: rhysd/github-action-benchmark@v1
with:
tool: "go"
output-file-path: output.txt
fail-on-alert: true
comment-on-alert: true
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: false
cov:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: 1.15
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Publish coverage
uses: paambaati/codeclimate-action@v2.7.4
env:
VERBOSE: "true"
GOMAXPROCS: 4
CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
with:
coverageCommand: make cov
prefix: github.com/${{ github.repository }}
coverageLocations: |
${{ github.workspace }}/coverage.out:gocov
test:
name: Test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go_version:
- "1.15"
- "1.16"
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: ${{ matrix.terraform_version }}
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Run tests
run: make test
env:
VERBOSE: "true"
benchmark-store:
name: Store benchmarks
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: 1.15
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Run benchmarks
run: make bench | tee output.raw
- name: Fix benchmark names
run: >-
perl -pe 's/^(Benchmark.+?)\/(\S+)(-\d+)(\s+)/\1__\2\4/' output.raw |
tr '-' '_' | tee output.txt
- name: Store benchmark result
uses: rhysd/github-action-benchmark@v1
with:
tool: "go"
output-file-path: output.txt
github-token: ${{ secrets.ROMDOBOT_TOKEN }}
comment-on-alert: true
auto-push: true

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/*.tidy-check
/bin/*
/coverage.out
/output.txt

93
.golangci.yml Normal file
View File

@@ -0,0 +1,93 @@
linters-settings:
funlen:
lines: 100
statements: 150
goconst:
min-occurrences: 5
gocyclo:
min-complexity: 20
golint:
min-confidence: 0
govet:
check-shadowing: true
enable-all: true
disable:
- fieldalignment
lll:
line-length: 80
tab-width: 4
maligned:
suggest-new: true
misspell:
locale: US
linters:
disable-all: true
enable:
- asciicheck
- bodyclose
- deadcode
- depguard
- dupl
- durationcheck
- errcheck
- errorlint
- exhaustive
- exportloopref
- funlen
- gochecknoinits
- goconst
- gocritic
- gocyclo
- godot
- gofumpt
- goimports
- goprintffuncname
- gosec
- gosimple
- govet
- importas
- ineffassign
- lll
- misspell
- nakedret
- nilerr
- noctx
- nolintlint
- prealloc
- predeclared
- revive
- rowserrcheck
- sqlclosecheck
- staticcheck
- structcheck
- tparallel
- typecheck
- unconvert
- unparam
- unused
- varcheck
- wastedassign
- whitespace
issues:
exclude:
- Using the variable on range scope `tt` in function literal
- Using the variable on range scope `tc` in function literal
exclude-rules:
- path: "_test\\.go"
linters:
- funlen
- dupl
- goconst
- source: "^//go:generate "
linters:
- lll
- source: "`json:"
linters:
- lll
run:
timeout: 2m
allow-parallel-runners: true
modules-download-mode: readonly

192
Makefile Normal file
View File

@@ -0,0 +1,192 @@
GOMODNAME := $(shell grep 'module' go.mod | sed -e 's/^module //')
SOURCES := $(shell find . -name "*.go" -or -name "go.mod" -or -name "go.sum" \
-or -name "Makefile")
# Verbose output
ifdef VERBOSE
V = -v
endif
#
# Environment
#
BINDIR := bin
TOOLDIR := $(BINDIR)/tools
# Global environment variables for all targets
SHELL ?= /bin/bash
SHELL := env \
GO111MODULE=on \
GOBIN=$(CURDIR)/$(TOOLDIR) \
CGO_ENABLED=1 \
PATH='$(CURDIR)/$(BINDIR):$(CURDIR)/$(TOOLDIR):$(PATH)' \
$(SHELL)
#
# Defaults
#
# Default target
.DEFAULT_GOAL := test
#
# Tools
#
TOOLS += $(TOOLDIR)/gobin
$(TOOLDIR)/gobin:
GO111MODULE=off go get -u github.com/myitcv/gobin
# external tool
define tool # 1: binary-name, 2: go-import-path
TOOLS += $(TOOLDIR)/$(1)
$(TOOLDIR)/$(1): $(TOOLDIR)/gobin Makefile
gobin $(V) "$(2)"
endef
$(eval $(call tool,godoc,golang.org/x/tools/cmd/godoc))
$(eval $(call tool,gofumports,mvdan.cc/gofumpt/gofumports))
$(eval $(call tool,golangci-lint,github.com/golangci/golangci-lint/cmd/golangci-lint@v1.41))
$(eval $(call tool,gomod,github.com/Helcaraxan/gomod))
.PHONY: tools
tools: $(TOOLS)
#
# Development
#
BENCH ?= .
TESTARGS ?=
.PHONY: clean
clean:
rm -f $(TOOLS)
rm -f ./coverage.out ./go.mod.tidy-check ./go.sum.tidy-check
.PHONY: test
test:
go test $(V) -count=1 -race $(TESTARGS) ./...
.PHONY: test-deps
test-deps:
go test all
.PHONY: lint
lint: $(TOOLDIR)/golangci-lint
golangci-lint $(V) run
.PHONY: format
format: $(TOOLDIR)/gofumports
gofumports -w .
.SILENT: bench
.PHONY: bench
bench:
go test $(V) -count=1 -bench=$(BENCH) $(TESTARGS) ./...
#
# Code Generation
#
.PHONY: generate
generate:
go generate ./...
.PHONY: check-generate
check-generate:
$(eval CHKDIR := $(shell mktemp -d))
cp -av . "$(CHKDIR)"
make -C "$(CHKDIR)/" generate
( diff -rN . "$(CHKDIR)" && rm -rf "$(CHKDIR)" ) || \
( rm -rf "$(CHKDIR)" && exit 1 )
#
# Coverage
#
.PHONY: cov
cov: coverage.out
.PHONY: cov-html
cov-html: coverage.out
go tool cover -html=./coverage.out
.PHONY: cov-func
cov-func: coverage.out
go tool cover -func=./coverage.out
coverage.out: $(SOURCES)
go test $(V) -covermode=count -coverprofile=./coverage.out ./...
#
# Dependencies
#
.PHONY: deps
deps:
go mod download
.PHONY: deps-update
deps-update:
go get -u -t ./...
.PHONY: deps-analyze
deps-analyze: $(TOOLDIR)/gomod
gomod analyze
.PHONY: tidy
tidy:
go mod tidy $(V)
.PHONY: verify
verify:
go mod verify
.SILENT: check-tidy
.PHONY: check-tidy
check-tidy:
cp go.mod go.mod.tidy-check
cp go.sum go.sum.tidy-check
go mod tidy
( \
diff go.mod go.mod.tidy-check && \
diff go.sum go.sum.tidy-check && \
rm -f go.mod go.sum && \
mv go.mod.tidy-check go.mod && \
mv go.sum.tidy-check go.sum \
) || ( \
rm -f go.mod go.sum && \
mv go.mod.tidy-check go.mod && \
mv go.sum.tidy-check go.sum; \
exit 1 \
)
#
# Documentation
#
# Serve docs
.PHONY: docs
docs: godoc
$(info serviing docs on http://127.0.0.1:6060/pkg/$(GOMODNAME)/)
@godoc -http=127.0.0.1:6060
#
# Release
#
.PHONY: new-version
new-version: check-npx
npx standard-version
.PHONY: next-version
next-version: check-npx
npx standard-version --dry-run
.PHONY: check-npx
check-npx:
$(if $(shell which npx),,\
$(error No npx found in PATH, please install NodeJS))

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/romdo/go-conventionalcommit
go 1.15
require github.com/stretchr/testify v1.7.0

11
go.sum Normal file
View File

@@ -0,0 +1,11 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

102
line.go Normal file
View File

@@ -0,0 +1,102 @@
package conventionalcommit
const (
lf = 10 // linefeed ("\n") character
cr = 13 // carriage return ("\r") character
)
// Line represents a single line of text defined as; A continuous sequence of
// bytes which do not contain a \r (carriage return) or \n (line-feed) byte.
type Line struct {
// Line number within commit message, starting a 1 rather than 0, as
// text viewed in a text editor starts on line 1, not line 0.
Number int
// Content is the raw bytes that make up the text content in the line.
Content []byte
// Break is the linebreak type used at the end of the line. It will be one
// of "\n", "\r\n", "\r", or empty if it is the very last line.
Break []byte
}
// Lines is a slice of *Line types with some helper methods attached.
type Lines []*Line
// NewLines breaks the given byte slice down into a slice of Line structs,
// allowing easier inspection and manipulation of content on a line-by-line
// basis.
func NewLines(content []byte) Lines {
r := Lines{}
if len(content) == 0 {
return r
}
// List of start/end offsets for each line break.
var breaks [][]int
// Locate each line break within content.
for i := 0; i < len(content); i++ {
if content[i] == lf {
breaks = append(breaks, []int{i, i + 1})
} else if content[i] == cr {
b := []int{i, i + 1}
if i+1 < len(content) && content[i+1] == lf {
b[1]++
i++
}
breaks = append(breaks, b)
}
}
// Return a single line if there are no line breaks.
if len(breaks) == 0 {
return Lines{{Number: 1, Content: content, Break: []byte{}}}
}
// Extract each line based on linebreak offsets.
offset := 0
for n, loc := range breaks {
r = append(r, &Line{
Number: n + 1,
Content: content[offset:loc[0]],
Break: content[loc[0]:loc[1]],
})
offset = loc[1]
}
// Extract final line
r = append(r, &Line{
Number: len(breaks) + 1,
Content: content[offset:],
Break: []byte{},
})
return r
}
// Bytes combines all Lines into a single byte slice, retaining the original
// line break types for each line.
func (s Lines) Bytes() []byte {
// Pre-calculate capacity of result byte slice.
size := 0
for _, l := range s {
size = size + len(l.Content) + len(l.Break)
}
b := make([]byte, 0, size)
for _, l := range s {
b = append(b, l.Content...)
b = append(b, l.Break...)
}
return b
}
// Bytes combines all Lines into a single string, retaining the original line
// break types for each line.
func (s Lines) String() string {
return string(s.Bytes())
}

594
line_test.go Normal file
View File

@@ -0,0 +1,594 @@
package conventionalcommit
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewLines(t *testing.T) {
tests := []struct {
name string
content []byte
want Lines
}{
{
name: "nil",
content: nil,
want: Lines{},
},
{
name: "empty",
content: []byte{},
want: Lines{},
},
{
name: "single line without trailing linebreak",
content: []byte("hello world"),
want: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte{},
},
},
},
{
name: "single line with trailing LF",
content: []byte("hello world\n"),
want: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte(""),
Break: []byte{},
},
},
},
{
name: "single line with trailing CRLF",
content: []byte("hello world\r\n"),
want: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\r\n"),
},
{
Number: 2,
Content: []byte(""),
Break: []byte{},
},
},
},
{
name: "single line with trailing CR",
content: []byte("hello world\r"),
want: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\r"),
},
{
Number: 2,
Content: []byte(""),
Break: []byte{},
},
},
},
{
name: "multiple lines separated by LF",
content: []byte("hello world\nfoo\nbar"),
want: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte("foo"),
Break: []byte("\n"),
},
{
Number: 3,
Content: []byte("bar"),
Break: []byte{},
},
},
},
{
name: "multiple lines separated by LF with trailing LF",
content: []byte("hello world\nfoo\nbar\n"),
want: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte("foo"),
Break: []byte("\n"),
},
{
Number: 3,
Content: []byte("bar"),
Break: []byte("\n"),
},
{
Number: 4,
Content: []byte(""),
Break: []byte{},
},
},
},
{
name: "multiple lines separated by CRLF",
content: []byte("hello world\r\nfoo\r\nbar"),
want: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\r\n"),
},
{
Number: 2,
Content: []byte("foo"),
Break: []byte("\r\n"),
},
{
Number: 3,
Content: []byte("bar"),
Break: []byte{},
},
},
},
{
name: "multiple lines separated by CRLF with trailing CRLF",
content: []byte("hello world\r\nfoo\r\nbar\r\n"),
want: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\r\n"),
},
{
Number: 2,
Content: []byte("foo"),
Break: []byte("\r\n"),
},
{
Number: 3,
Content: []byte("bar"),
Break: []byte("\r\n"),
},
{
Number: 4,
Content: []byte(""),
Break: []byte{},
},
},
},
{
name: "multiple lines separated by CR",
content: []byte("hello world\rfoo\rbar"),
want: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\r"),
},
{
Number: 2,
Content: []byte("foo"),
Break: []byte("\r"),
},
{
Number: 3,
Content: []byte("bar"),
Break: []byte{},
},
},
},
{
name: "multiple lines separated by CR with trailing CR",
content: []byte("hello world\rfoo\rbar\r"),
want: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\r"),
},
{
Number: 2,
Content: []byte("foo"),
Break: []byte("\r"),
},
{
Number: 3,
Content: []byte("bar"),
Break: []byte("\r"),
},
{
Number: 4,
Content: []byte(""),
Break: []byte{},
},
},
},
{
name: "multiple lines separated by mixed break types",
content: []byte("hello\nworld\r\nfoo\rbar"),
want: Lines{
{
Number: 1,
Content: []byte("hello"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte("world"),
Break: []byte("\r\n"),
},
{
Number: 3,
Content: []byte("foo"),
Break: []byte("\r"),
},
{
Number: 4,
Content: []byte("bar"),
Break: []byte{},
},
},
},
{
name: "multiple lines separated by mixed break types with " +
"trailing LF",
content: []byte("hello\nworld\r\nfoo\rbar\n"),
want: Lines{
{
Number: 1,
Content: []byte("hello"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte("world"),
Break: []byte("\r\n"),
},
{
Number: 3,
Content: []byte("foo"),
Break: []byte("\r"),
},
{
Number: 4,
Content: []byte("bar"),
Break: []byte("\n"),
},
{
Number: 5,
Content: []byte(""),
Break: []byte{},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NewLines(tt.content)
assert.Equal(t, tt.want, got)
})
}
}
var linesBytesTestCases = []struct {
name string
lines Lines
want []byte
}{
{
name: "single line",
lines: Lines{
{
Number: 1,
Content: []byte("hello world"),
},
},
want: []byte("hello world"),
},
{
name: "single line with trailing LF",
lines: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte(""),
Break: []byte{},
},
},
want: []byte("hello world\n"),
},
{
name: "single line with trailing CRLF",
lines: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\r\n"),
},
{
Number: 2,
Content: []byte(""),
Break: []byte{},
},
},
want: []byte("hello world\r\n"),
},
{
name: "single line with trailing CR",
lines: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\r"),
},
{
Number: 2,
Content: []byte(""),
Break: []byte{},
},
},
want: []byte("hello world\r"),
},
{
name: "multi-line separated by LF",
lines: Lines{
{
Number: 3,
Content: []byte("Aliquam feugiat tellus ut neque."),
Break: []byte("\n"),
},
{
Number: 4,
Content: []byte("Sed bibendum."),
Break: []byte("\n"),
},
{
Number: 5,
Content: []byte("Nullam libero mauris, consequat."),
Break: []byte("\n"),
},
{
Number: 6,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 7,
Content: []byte("Integer placerat tristique nisl."),
Break: []byte("\n"),
},
{
Number: 8,
Content: []byte("Etiam vel neque nec dui bibendum."),
Break: []byte("\n"),
},
{
Number: 9,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 10,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 11,
Content: []byte("Nullam libero mauris, dictum id, arcu."),
Break: []byte("\n"),
},
{
Number: 12,
Content: []byte(""),
Break: []byte{},
},
},
want: []byte(
"Aliquam feugiat tellus ut neque.\n" +
"Sed bibendum.\n" +
"Nullam libero mauris, consequat.\n" +
"\n" +
"Integer placerat tristique nisl.\n" +
"Etiam vel neque nec dui bibendum.\n" +
"\n" +
"\n" +
"Nullam libero mauris, dictum id, arcu.\n",
),
},
{
name: "multi-line separated by CRLF",
lines: Lines{
{
Number: 3,
Content: []byte("Aliquam feugiat tellus ut neque."),
Break: []byte("\r\n"),
},
{
Number: 4,
Content: []byte("Sed bibendum."),
Break: []byte("\r\n"),
},
{
Number: 5,
Content: []byte("Nullam libero mauris, consequat."),
Break: []byte("\r\n"),
},
{
Number: 6,
Content: []byte(""),
Break: []byte("\r\n"),
},
{
Number: 7,
Content: []byte("Integer placerat tristique nisl."),
Break: []byte("\r\n"),
},
{
Number: 8,
Content: []byte("Etiam vel neque nec dui bibendum."),
Break: []byte("\r\n"),
},
{
Number: 9,
Content: []byte(""),
Break: []byte("\r\n"),
},
{
Number: 10,
Content: []byte(""),
Break: []byte("\r\n"),
},
{
Number: 11,
Content: []byte("Nullam libero mauris, dictum id, arcu."),
Break: []byte("\r\n"),
},
{
Number: 12,
Content: []byte(""),
Break: []byte{},
},
},
want: []byte(
"Aliquam feugiat tellus ut neque.\r\n" +
"Sed bibendum.\r\n" +
"Nullam libero mauris, consequat.\r\n" +
"\r\n" +
"Integer placerat tristique nisl.\r\n" +
"Etiam vel neque nec dui bibendum.\r\n" +
"\r\n" +
"\r\n" +
"Nullam libero mauris, dictum id, arcu.\r\n",
),
},
{
name: "multi-line separated by CR",
lines: Lines{
{
Number: 3,
Content: []byte("Aliquam feugiat tellus ut neque."),
Break: []byte("\r"),
},
{
Number: 4,
Content: []byte("Sed bibendum."),
Break: []byte("\r"),
},
{
Number: 5,
Content: []byte("Nullam libero mauris, consequat."),
Break: []byte("\r"),
},
{
Number: 6,
Content: []byte(""),
Break: []byte("\r"),
},
{
Number: 7,
Content: []byte("Integer placerat tristique nisl."),
Break: []byte("\r"),
},
{
Number: 8,
Content: []byte("Etiam vel neque nec dui bibendum."),
Break: []byte("\r"),
},
{
Number: 9,
Content: []byte(""),
Break: []byte("\r"),
},
{
Number: 10,
Content: []byte(""),
Break: []byte("\r"),
},
{
Number: 11,
Content: []byte("Nullam libero mauris, dictum id, arcu."),
Break: []byte("\r"),
},
{
Number: 12,
Content: []byte(""),
Break: []byte{},
},
},
want: []byte(
"Aliquam feugiat tellus ut neque.\r" +
"Sed bibendum.\r" +
"Nullam libero mauris, consequat.\r" +
"\r" +
"Integer placerat tristique nisl.\r" +
"Etiam vel neque nec dui bibendum.\r" +
"\r" +
"\r" +
"Nullam libero mauris, dictum id, arcu.\r",
),
},
}
func TestLines_Bytes(t *testing.T) {
for _, tt := range linesBytesTestCases {
t.Run(tt.name, func(t *testing.T) {
got := tt.lines.Bytes()
assert.Equal(t, tt.want, got)
})
}
}
func BenchmarkLines_Bytes(b *testing.B) {
for _, tt := range linesBytesTestCases {
b.Run(tt.name, func(b *testing.B) {
for n := 0; n < b.N; n++ {
_ = tt.lines.Bytes()
}
})
}
}
func TestLines_String(t *testing.T) {
for _, tt := range linesBytesTestCases {
t.Run(tt.name, func(t *testing.T) {
got := tt.lines.String()
assert.Equal(t, string(tt.want), got)
})
}
}
func BenchmarkLines_String(b *testing.B) {
for _, tt := range linesBytesTestCases {
b.Run(tt.name, func(b *testing.B) {
for n := 0; n < b.N; n++ {
_ = tt.lines.String()
}
})
}
}

30
paragraph.go Normal file
View File

@@ -0,0 +1,30 @@
package conventionalcommit
import "bytes"
// Paragraph represents a textual paragraph defined as; A continuous sequence of
// textual lines which are not empty or and do not consist of only whitespace.
type Paragraph struct {
// Lines is a list of lines which collectively form a paragraph.
Lines Lines
}
func NewParagraphs(lines Lines) []*Paragraph {
r := []*Paragraph{}
paragraph := &Paragraph{Lines: Lines{}}
for _, line := range lines {
if len(bytes.TrimSpace(line.Content)) > 0 {
paragraph.Lines = append(paragraph.Lines, line)
} else if len(paragraph.Lines) > 0 {
r = append(r, paragraph)
paragraph = &Paragraph{Lines: Lines{}}
}
}
if len(paragraph.Lines) > 0 {
r = append(r, paragraph)
}
return r
}

338
paragraph_test.go Normal file
View File

@@ -0,0 +1,338 @@
package conventionalcommit
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewParagraphs(t *testing.T) {
tests := []struct {
name string
lines Lines
want []*Paragraph
}{
{
name: "nil",
lines: nil,
want: []*Paragraph{},
},
{
name: "no lines",
lines: Lines{},
want: []*Paragraph{},
},
{
name: "single empty line",
lines: Lines{
{
Number: 1,
Content: []byte{},
Break: []byte{},
},
},
want: []*Paragraph{},
},
{
name: "multiple empty lines",
lines: Lines{
{
Number: 1,
Content: []byte{},
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte{},
Break: []byte("\n"),
},
{
Number: 3,
Content: []byte{},
Break: []byte{},
},
},
want: []*Paragraph{},
},
{
name: "single whitespace line",
lines: Lines{
{
Number: 1,
Content: []byte("\t "),
Break: []byte{},
},
},
want: []*Paragraph{},
},
{
name: "multiple whitespace lines",
lines: Lines{
{
Number: 1,
Content: []byte{},
Break: []byte("\t "),
},
{
Number: 2,
Content: []byte{},
Break: []byte("\t "),
},
{
Number: 3,
Content: []byte("\t "),
Break: []byte{},
},
},
want: []*Paragraph{},
},
{
name: "single line",
lines: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte{},
},
},
want: []*Paragraph{
{
Lines: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte{},
},
},
},
},
},
{
name: "multiple lines",
lines: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte("foo bar"),
Break: []byte{},
},
},
want: []*Paragraph{
{
Lines: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte("foo bar"),
Break: []byte{},
},
},
},
},
},
{
name: "multiple lines with trailing line break",
lines: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte("foo bar"),
Break: []byte("\n"),
},
{
Number: 3,
Content: []byte(""),
Break: []byte{},
},
},
want: []*Paragraph{
{
Lines: Lines{
{
Number: 1,
Content: []byte("hello world"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte("foo bar"),
Break: []byte("\n"),
},
},
},
},
},
{
name: "multiple paragraphs with excess blank lines",
lines: Lines{
{
Number: 1,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte("\t "),
Break: []byte("\r\n"),
},
{
Number: 3,
Content: []byte("Aliquam feugiat tellus ut neque."),
Break: []byte("\r"),
},
{
Number: 4,
Content: []byte("Sed bibendum."),
Break: []byte("\r"),
},
{
Number: 5,
Content: []byte("Nullam libero mauris, consequat."),
Break: []byte("\n"),
},
{
Number: 6,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 7,
Content: []byte("Integer placerat tristique nisl."),
Break: []byte("\n"),
},
{
Number: 8,
Content: []byte("Etiam vel neque nec dui bibendum."),
Break: []byte("\n"),
},
{
Number: 9,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 10,
Content: []byte(" "),
Break: []byte("\n"),
},
{
Number: 11,
Content: []byte("\t\t"),
Break: []byte("\n"),
},
{
Number: 12,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 13,
Content: []byte("Donec hendrerit tempor tellus."),
Break: []byte("\n"),
},
{
Number: 14,
Content: []byte("In id erat non orci commodo lobortis."),
Break: []byte("\n"),
},
{
Number: 15,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 16,
Content: []byte(" "),
Break: []byte("\n"),
},
{
Number: 17,
Content: []byte("\t\t"),
Break: []byte("\n"),
},
{
Number: 18,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 18,
Content: []byte(""),
Break: []byte{},
},
},
want: []*Paragraph{
{
Lines: Lines{
{
Number: 3,
Content: []byte("Aliquam feugiat tellus ut neque."),
Break: []byte("\r"),
},
{
Number: 4,
Content: []byte("Sed bibendum."),
Break: []byte("\r"),
},
{
Number: 5,
Content: []byte("Nullam libero mauris, consequat."),
Break: []byte("\n"),
},
},
},
{
Lines: Lines{
{
Number: 7,
Content: []byte("Integer placerat tristique nisl."),
Break: []byte("\n"),
},
{
Number: 8,
Content: []byte(
"Etiam vel neque nec dui bibendum.",
),
Break: []byte("\n"),
},
},
},
{
Lines: Lines{
{
Number: 13,
Content: []byte("Donec hendrerit tempor tellus."),
Break: []byte("\n"),
},
{
Number: 14,
Content: []byte(
"In id erat non orci commodo lobortis.",
),
Break: []byte("\n"),
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NewParagraphs(tt.lines)
assert.Equal(t, tt.want, got)
})
}
}

50
raw_message.go Normal file
View File

@@ -0,0 +1,50 @@
package conventionalcommit
// RawMessage 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.
type RawMessage struct {
// Lines is a list of all individual lines of text in the commit message,
// which also includes the original line number, making it easy to pass a
// single Line around while still knowing where in the original commit
// message it belongs.
Lines Lines
// Paragraphs is a list of textual paragraphs in the commit message. A
// paragraph is defined as any continuous sequence of lines which are not
// empty or consist of only whitespace.
Paragraphs []*Paragraph
}
// NewRawMessage returns a RawMessage, with the given commit message broken down
// into individual lines of text, with sequential non-empty lines grouped into
// paragraphs.
func NewRawMessage(message []byte) *RawMessage {
r := &RawMessage{
Lines: Lines{},
Paragraphs: []*Paragraph{},
}
if len(message) == 0 {
return r
}
r.Lines = NewLines(message)
r.Paragraphs = NewParagraphs(r.Lines)
return r
}
// Bytes renders the RawMessage back into a byte slice which is identical to the
// original input byte slice given to NewRawMessage. This includes retaining the
// original line break types for each line.
func (s *RawMessage) Bytes() []byte {
return s.Lines.Bytes()
}
// String renders the RawMessage back into a string which is identical to the
// original input byte slice given to NewRawMessage. This includes retaining the
// original line break types for each line.
func (s *RawMessage) String() string {
return s.Lines.String()
}

641
raw_message_test.go Normal file
View File

@@ -0,0 +1,641 @@
package conventionalcommit
import (
"testing"
"github.com/stretchr/testify/assert"
)
var rawMessageTestCases = []struct {
name string
bytes []byte
rawMessage *RawMessage
}{
{
name: "nil",
bytes: nil,
rawMessage: &RawMessage{
Lines: Lines{},
Paragraphs: []*Paragraph{},
},
},
{
name: "empty",
bytes: []byte(""),
rawMessage: &RawMessage{
Lines: Lines{},
Paragraphs: []*Paragraph{},
},
},
{
name: "single space",
bytes: []byte(" "),
rawMessage: &RawMessage{
Lines: Lines{
{
Number: 1,
Content: []byte(" "),
Break: []byte{},
},
},
Paragraphs: []*Paragraph{},
},
},
{
name: "subject only",
bytes: []byte("fix: a broken thing"),
rawMessage: &RawMessage{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: a broken thing"),
Break: []byte{},
},
},
Paragraphs: []*Paragraph{
{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: a broken thing"),
Break: []byte{},
},
},
},
},
},
},
{
name: "subject and body",
bytes: []byte("fix: a broken thing\n\nIt is now fixed."),
rawMessage: &RawMessage{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: a broken thing"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 3,
Content: []byte("It is now fixed."),
Break: []byte{},
},
},
Paragraphs: []*Paragraph{
{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: a broken thing"),
Break: []byte("\n"),
},
},
},
{
Lines: Lines{
{
Number: 3,
Content: []byte("It is now fixed."),
Break: []byte{},
},
},
},
},
},
},
{
name: "subject and body with CRLF line breaks",
bytes: []byte("fix: a broken thing\r\n\r\nIt is now fixed."),
rawMessage: &RawMessage{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: a broken thing"),
Break: []byte("\r\n"),
},
{
Number: 2,
Content: []byte(""),
Break: []byte("\r\n"),
},
{
Number: 3,
Content: []byte("It is now fixed."),
Break: []byte{},
},
},
Paragraphs: []*Paragraph{
{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: a broken thing"),
Break: []byte("\r\n"),
},
},
},
{
Lines: Lines{
{
Number: 3,
Content: []byte("It is now fixed."),
Break: []byte{},
},
},
},
},
},
},
{
name: "subject and body with CR line breaks",
bytes: []byte("fix: a broken thing\r\rIt is now fixed."),
rawMessage: &RawMessage{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: a broken thing"),
Break: []byte("\r"),
},
{
Number: 2,
Content: []byte(""),
Break: []byte("\r"),
},
{
Number: 3,
Content: []byte("It is now fixed."),
Break: []byte{},
},
},
Paragraphs: []*Paragraph{
{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: a broken thing"),
Break: []byte("\r"),
},
},
},
{
Lines: Lines{
{
Number: 3,
Content: []byte("It is now fixed."),
Break: []byte{},
},
},
},
},
},
},
{
name: "separated by whitespace line",
bytes: []byte("fix: a broken thing\n \nIt is now fixed."),
rawMessage: &RawMessage{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: a broken thing"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte(" "),
Break: []byte("\n"),
},
{
Number: 3,
Content: []byte("It is now fixed."),
Break: []byte{},
},
},
Paragraphs: []*Paragraph{
{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: a broken thing"),
Break: []byte("\n"),
},
},
},
{
Lines: Lines{
{
Number: 3,
Content: []byte("It is now fixed."),
Break: []byte{},
},
},
},
},
},
},
{
name: "subject and long body",
bytes: []byte(`fix: something broken
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Donec hendrerit
tempor tellus. Donec pretium posuere tellus. Proin quam nisl, tincidunt et,
mattis eget, convallis nec, purus. Cum sociis natoque penatibus et magnis dis
parturient montes, nascetur ridiculous mus. Nulla posuere. Donec vitae dolor.
Nullam tristique diam non turpis. Cras placerat accumsan nulla. Nullam rutrum.
Nam vestibulum accumsan nisl.
Nullam eu ante vel est convallis dignissim. Fusce suscipit, wisi nec facilisis
facilisis, est dui fermentum leo, quis tempor ligula erat quis odio. Nunc porta
vulputate tellus. Nunc rutrum turpis sed pede. Sed bibendum. Aliquam posuere.
Nunc aliquet, augue nec adipiscing interdum, lacus tellus malesuada massa, quis
varius mi purus non odio. Pellentesque condimentum, magna ut suscipit hendrerit,
ipsum augue ornare nulla, non luctus diam neque sit amet urna. Curabitur
vulputate vestibulum lorem. Fusce sagittis, libero non molestie mollis, magna
orci ultrices dolor, at vulputate neque nulla lacinia eros. Sed id ligula quis
est convallis tempor. Curabitur lacinia pulvinar nibh. Nam a sapien.
Phasellus lacus. Nam euismod tellus id erat.`),
rawMessage: &RawMessage{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: something broken"),
Break: []byte("\n"),
},
{
Number: 2,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 3,
Content: []byte(
"Lorem ipsum dolor sit amet, consectetuer " +
"adipiscing elit. Donec hendrerit"),
Break: []byte("\n"),
},
{
Number: 4,
Content: []byte(
"tempor tellus. Donec pretium posuere tellus. " +
"Proin quam nisl, tincidunt et,"),
Break: []byte("\n"),
},
{
Number: 5,
Content: []byte(
"mattis eget, convallis nec, purus. Cum sociis " +
"natoque penatibus et magnis dis"),
Break: []byte("\n"),
},
{
Number: 6,
Content: []byte(
"parturient montes, nascetur ridiculous mus. " +
"Nulla posuere. Donec vitae dolor."),
Break: []byte("\n"),
},
{
Number: 7,
Content: []byte(
"Nullam tristique diam non turpis. Cras placerat " +
"accumsan nulla. Nullam rutrum."),
Break: []byte("\n"),
},
{
Number: 8,
Content: []byte(
"Nam vestibulum accumsan nisl."),
Break: []byte("\n"),
},
{
Number: 9,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 10,
Content: []byte(
"Nullam eu ante vel est convallis dignissim. " +
"Fusce suscipit, wisi nec facilisis",
),
Break: []byte("\n"),
},
{
Number: 11,
Content: []byte(
"facilisis, est dui fermentum leo, quis tempor " +
"ligula erat quis odio. Nunc porta",
),
Break: []byte("\n"),
},
{
Number: 12,
Content: []byte(
"vulputate tellus. Nunc rutrum turpis sed pede. " +
"Sed bibendum. Aliquam posuere.",
),
Break: []byte("\n"),
},
{
Number: 13,
Content: []byte(
"Nunc aliquet, augue nec adipiscing interdum, " +
"lacus tellus malesuada massa, quis",
),
Break: []byte("\n"),
},
{
Number: 14,
Content: []byte(
"varius mi purus non odio. Pellentesque " +
"condimentum, magna ut suscipit hendrerit,",
),
Break: []byte("\n"),
},
{
Number: 15,
Content: []byte(
"ipsum augue ornare nulla, non luctus diam neque " +
"sit amet urna. Curabitur",
),
Break: []byte("\n"),
},
{
Number: 16,
Content: []byte(
"vulputate vestibulum lorem. Fusce sagittis, " +
"libero non molestie mollis, magna",
),
Break: []byte("\n"),
},
{
Number: 17,
Content: []byte(
"orci ultrices dolor, at vulputate neque nulla " +
"lacinia eros. Sed id ligula quis",
),
Break: []byte("\n"),
},
{
Number: 18,
Content: []byte(
"est convallis tempor. Curabitur lacinia " +
"pulvinar nibh. Nam a sapien.",
),
Break: []byte("\n"),
},
{
Number: 19,
Content: []byte(""),
Break: []byte("\n"),
},
{
Number: 20,
Content: []byte(
"Phasellus lacus. Nam euismod tellus id erat.",
),
Break: []byte{},
},
},
Paragraphs: []*Paragraph{
{
Lines: Lines{
{
Number: 1,
Content: []byte("fix: something broken"),
Break: []byte("\n"),
},
},
},
{
Lines: Lines{
{
Number: 3,
Content: []byte(
"Lorem ipsum dolor sit amet, " +
"consectetuer adipiscing elit. Donec " +
"hendrerit",
),
Break: []byte("\n"),
},
{
Number: 4,
Content: []byte(
"tempor tellus. Donec pretium posuere " +
"tellus. Proin quam nisl, tincidunt " +
"et,",
),
Break: []byte("\n"),
},
{
Number: 5,
Content: []byte(
"mattis eget, convallis nec, purus. Cum " +
"sociis natoque penatibus et magnis " +
"dis",
),
Break: []byte("\n"),
},
{
Number: 6,
Content: []byte(
"parturient montes, nascetur ridiculous " +
"mus. Nulla posuere. Donec vitae " +
"dolor.",
),
Break: []byte("\n"),
},
{
Number: 7,
Content: []byte(
"Nullam tristique diam non turpis. Cras " +
"placerat accumsan nulla. Nullam " +
"rutrum.",
),
Break: []byte("\n"),
},
{
Number: 8,
Content: []byte(
"Nam vestibulum accumsan nisl.",
),
Break: []byte("\n"),
},
},
},
{
Lines: Lines{
{
Number: 10,
Content: []byte(
"Nullam eu ante vel est convallis " +
"dignissim. Fusce suscipit, wisi nec " +
"facilisis",
),
Break: []byte("\n"),
},
{
Number: 11,
Content: []byte(
"facilisis, est dui fermentum leo, quis " +
"tempor ligula erat quis odio. Nunc " +
"porta",
),
Break: []byte("\n"),
},
{
Number: 12,
Content: []byte(
"vulputate tellus. Nunc rutrum turpis " +
"sed pede. Sed bibendum. Aliquam " +
"posuere.",
),
Break: []byte("\n"),
},
{
Number: 13,
Content: []byte(
"Nunc aliquet, augue nec adipiscing " +
"interdum, lacus tellus malesuada " +
"massa, quis",
),
Break: []byte("\n"),
},
{
Number: 14,
Content: []byte(
"varius mi purus non odio. Pellentesque " +
"condimentum, magna ut suscipit " +
"hendrerit,",
),
Break: []byte("\n"),
},
{
Number: 15,
Content: []byte(
"ipsum augue ornare nulla, non luctus " +
"diam neque sit amet urna. Curabitur",
),
Break: []byte("\n"),
},
{
Number: 16,
Content: []byte(
"vulputate vestibulum lorem. Fusce " +
"sagittis, libero non molestie " +
"mollis, magna",
),
Break: []byte("\n"),
},
{
Number: 17,
Content: []byte(
"orci ultrices dolor, at vulputate neque " +
"nulla lacinia eros. Sed id ligula " +
"quis",
),
Break: []byte("\n"),
},
{
Number: 18,
Content: []byte(
"est convallis tempor. Curabitur lacinia " +
"pulvinar nibh. Nam a sapien.",
),
Break: []byte("\n"),
},
},
},
{
Lines: Lines{
{
Number: 20,
Content: []byte(
"Phasellus lacus. Nam euismod tellus id " +
"erat.",
),
Break: []byte{},
},
},
},
},
},
},
}
func TestNewRawMessage(t *testing.T) {
for _, tt := range rawMessageTestCases {
t.Run(tt.name, func(t *testing.T) {
got := NewRawMessage(tt.bytes)
assert.Equal(t, tt.rawMessage, got)
})
}
}
func BenchmarkNewRawMessage(b *testing.B) {
for _, tt := range rawMessageTestCases {
b.Run(tt.name, func(b *testing.B) {
for n := 0; n < b.N; n++ {
_ = NewRawMessage(tt.bytes)
}
})
}
}
func TestRawMessage_Bytes(t *testing.T) {
for _, tt := range rawMessageTestCases {
if tt.bytes == nil {
continue
}
t.Run(tt.name, func(t *testing.T) {
got := tt.rawMessage.Bytes()
assert.Equal(t, tt.bytes, got)
})
}
}
func BenchmarkRawMessage_Bytes(b *testing.B) {
for _, tt := range rawMessageTestCases {
if tt.bytes == nil {
continue
}
b.Run(tt.name, func(b *testing.B) {
for n := 0; n < b.N; n++ {
_ = tt.rawMessage.Bytes()
}
})
}
}
func TestRawMessage_String(t *testing.T) {
for _, tt := range rawMessageTestCases {
if tt.bytes == nil {
continue
}
t.Run(tt.name, func(t *testing.T) {
got := tt.rawMessage.String()
assert.Equal(t, string(tt.bytes), got)
})
}
}
func BenchmarkRawMessage_String(b *testing.B) {
for _, tt := range rawMessageTestCases {
if tt.bytes == nil {
continue
}
b.Run(tt.name, func(b *testing.B) {
for n := 0; n < b.N; n++ {
_ = tt.rawMessage.String()
}
})
}
}