From 6cdaf8a476047f4c50e669112a1ff4004e26d2ac Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Thu, 26 Nov 2020 02:48:17 +0000 Subject: [PATCH 1/2] feat(undent): add initial implementation of String, Stringf, and Bytes --- .gitignore | 4 + .golangci.yml | 78 +++++++++ LICENSE | 20 +++ Makefile | 165 ++++++++++++++++++ README.md | 19 ++ go.mod | 5 + go.sum | 11 ++ undent.go | 73 ++++++++ undent_example_test.go | 51 ++++++ undent_test.go | 386 +++++++++++++++++++++++++++++++++++++++++ 10 files changed, 812 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 undent.go create mode 100644 undent_example_test.go create mode 100644 undent_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2663299 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/*.tidy-check +/bin/* +/coverage.out +/output.txt diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b5356f5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,78 @@ +linters-settings: + funlen: + lines: 100 + statements: 150 + gocyclo: + min-complexity: 20 + golint: + min-confidence: 0 + govet: + check-shadowing: true + enable-all: true + lll: + line-length: 80 + tab-width: 4 + maligned: + suggest-new: true + misspell: + locale: US + +linters: + disable-all: true + enable: + - bodyclose + - deadcode + - depguard + - dupl + - errcheck + - funlen + - gochecknoinits + - goconst + - gocritic + - gocyclo + - goerr113 + - goimports + - golint + - goprintffuncname + - gosec + - gosimple + - govet + - ineffassign + - lll + - misspell + - nakedret + - nlreturn + - noctx + - nolintlint + - scopelint + - sqlclosecheck + - staticcheck + - structcheck + - typecheck + - unconvert + - unused + - varcheck + - whitespace + +issues: + include: + # - EXC0002 # disable excluding of issues about comments from golint + 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 + - source: "^//go:generate " + linters: + - lll + - source: "`json:" + linters: + - lll + +run: + timeout: 2m + allow-parallel-runners: true + modules-download-mode: readonly diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..550e4c6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2020 Jim Myhrberg + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d984973 --- /dev/null +++ b/Makefile @@ -0,0 +1,165 @@ +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 +gobin: $(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) + +.PHONY: $(1) +$(1): $(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.31)) + +.PHONY: tools +tools: $(TOOLS) + +# +# Development +# + +TEST ?= $$(go list ./... | grep -v 'vendor') +BENCH ?= . + +.PHONY: clean +clean: + rm -f $(TOOLS) + rm -f ./coverage.out ./go.mod.tidy-check ./go.sum.tidy-check + +.PHONY: clean-golden +clean-golden: + rm -f $(shell find * -path '*/testdata/*' -name "*.golden" \ + -exec echo "'{}'" \;) + +.PHONY: test +test: + go test $(V) -count=1 -race $(TESTARGS) $(TEST) + +.PHONY: test-update-golden +test-update-golden: + @$(MAKE) test UPDATE_GOLDEN=1 + +.PHONY: regen-golden +regen-golden: clean-golden test-update-golden + +.PHONY: test-deps +test-deps: + go test all + +.PHONY: lint +lint: golangci-lint + GOGC=off golangci-lint $(V) run + +.PHONY: format +format: gofumports + gofumports -w . + +.SILENT: bench +.PHONY: bench +bench: + go test $(V) -count=1 -bench=$(BENCH) $(TESTARGS) $(TEST) + +# +# 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: + $(info Downloading dependencies) + go mod download + +.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 + @godoc -http=127.0.0.1:6060 diff --git a/README.md b/README.md index 107a684..15e1b2a 100644 --- a/README.md +++ b/README.md @@ -1 +1,20 @@ # undent + + +Go package which removes leading indentation/white-space from multi-line strings +and byte slices. + +```go +s := undent.String(` + { + "hello": "world" + }`, +) +fmt.Println(s) +``` + +``` +{ + "hello": "world" +} +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..414655d --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/jimeh/undent + +go 1.15 + +require github.com/stretchr/testify v1.6.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..afe7890 --- /dev/null +++ b/go.sum @@ -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.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/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= diff --git a/undent.go b/undent.go new file mode 100644 index 0000000..86503be --- /dev/null +++ b/undent.go @@ -0,0 +1,73 @@ +// Package undent removes leading indentation/white-space from strings and byte +// slices. +package undent + +import ( + "fmt" + "regexp" +) + +var matcher = regexp.MustCompile(`(?m)^([ \t]*)(?:\S)`) + +// Bytes removes leading indentation/white-space from given byte slice. +func Bytes(b []byte) []byte { + matches := matcher.FindAll(b, -1) + if len(matches) == 0 { + return b + } + + index := 0 + length := len(matches[0]) + + for i, s := range matches[1:] { + l := len(s) + if l < length { + index = i + 1 + length = l + } + } + + if length <= 1 { + return b + } + indent := matches[index][0 : length-1] + + return regexp.MustCompile( + `(?m)^`+regexp.QuoteMeta(string(indent)), + ).ReplaceAllLiteral(b, []byte{}) +} + +// String removes leading indentation/white-space from given string. +func String(s string) string { + matches := matcher.FindAllString(s, -1) + if len(matches) == 0 { + return s + } + + index := 0 + length := len(matches[0]) + + for i, s := range matches[1:] { + l := len(s) + if l < length { + index = i + 1 + length = l + } + } + + if length <= 1 { + return s + } + indent := matches[index][0 : length-1] + + return regexp.MustCompile( + `(?m)^`+regexp.QuoteMeta(indent), + ).ReplaceAllLiteralString(s, "") +} + +// Stringf removes leading indentation/white-space from given format string +// before passing format and all additional arguments to fmt.Sprintf, returning +// the result. +func Stringf(format string, a ...interface{}) string { + return fmt.Sprintf(String(format), a...) +} diff --git a/undent_example_test.go b/undent_example_test.go new file mode 100644 index 0000000..5959f2a --- /dev/null +++ b/undent_example_test.go @@ -0,0 +1,51 @@ +package undent_test + +import ( + "fmt" + + "github.com/jimeh/undent" +) + +func ExampleBytes() { + b := undent.Bytes([]byte(` + { + "hello": "world" + }`, + )) + + fmt.Println(string(b)) + // Output: + // + // { + // "hello": "world" + // } +} + +func ExampleString() { + s := undent.String(` + { + "hello": "world" + }`, + ) + fmt.Println(s) + // Output: + // + // { + // "hello": "world" + // } +} + +func ExampleStringf() { + s := undent.Stringf(` + { + "hello": "%s" + }`, + "world", + ) + fmt.Println(s) + // Output: + // + // { + // "hello": "world" + // } +} diff --git a/undent_test.go b/undent_test.go new file mode 100644 index 0000000..3f2f503 --- /dev/null +++ b/undent_test.go @@ -0,0 +1,386 @@ +package undent + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var stringTestCases = []struct { + name string + s string + want string +}{ + { + name: "empty", + s: "", + want: "", + }, + { + name: "single-line", + s: "hello world", + want: "hello world", + }, + { + name: "single-line indented", + s: " hello world", + want: "hello world", + }, + { + name: "multi-line", + s: ` +{ + "hello": "world", + "foo": [ + "bar" + ] +}`, + want: ` +{ + "hello": "world", + "foo": [ + "bar" + ] +}`, + }, + { + name: "multi-line space indented", + s: ` + { + "hello": "world", + "foo": [ + "bar" + ] + }`, + want: ` +{ + "hello": "world", + "foo": [ + "bar" + ] +}`, + }, + { + name: "multi-line tab indented", + s: ` + { + "hello": "world", + "foo": [ + "bar" + ] + }`, + want: ` +{ + "hello": "world", + "foo": [ + "bar" + ] +}`, + }, + { + name: "multi-line tab indented with tabs and spaces after indent", + s: ` + { + "hello": "world", + "foo": [ + "bar" + ] + }`, + want: ` +{ + "hello": "world", + "foo": [ + "bar" + ] +}`, + }, + { + name: "multi-line space indented with blank lines", + s: ` + { + "hello": "world", + "foo": [ + + "bar" + + ] + }`, + want: ` +{ + "hello": "world", + "foo": [ + + "bar" + + ] +}`, + }, + { + name: "multi-line tab indented with blank lines", + s: ` + { + "hello": "world", + "foo": [ + + "bar" + + ] + }`, + want: ` +{ + "hello": "world", + "foo": [ + + "bar" + + ] +}`, + }, + { + name: "multi-line space indented with random indentation", + s: ` + hello + world + foo + bar`, + want: ` + hello +world + foo + bar`, + }, + { + name: "multi-line tab indented with random indentation", + s: ` + hello + world + foo + bar`, + want: ` + hello +world + foo + bar`, + }, +} + +var stringfTestCases = []struct { + name string + s string + a []interface{} + want string +}{ + { + name: "empty", + s: "", + want: "", + }, + { + name: "single-line", + s: "hello %s, %d", + a: []interface{}{"world", 42}, + want: "hello world, 42", + }, + { + name: "single-line indented", + s: " hello %s, %d", + a: []interface{}{"world", 42}, + want: "hello world, 42", + }, + { + name: "multi-line", + s: ` +{ + "hello": "%s", + "foo": [ + %d + ] +}`, + a: []interface{}{"world", 42}, + want: ` +{ + "hello": "world", + "foo": [ + 42 + ] +}`, + }, + { + name: "multi-line space indented", + s: ` + { + "hello": "%s", + "foo": [ + %d + ] + }`, + a: []interface{}{"world", 42}, + want: ` +{ + "hello": "world", + "foo": [ + 42 + ] +}`, + }, + { + name: "multi-line tab indented", + s: ` + { + "hello": "%s", + "foo": [ + %d + ] + }`, + a: []interface{}{"world", 42}, + want: ` +{ + "hello": "world", + "foo": [ + 42 + ] +}`, + }, + { + name: "multi-line tab indented with tabs and spaces after indent", + s: ` + { + "hello": "%s", + "foo": [ + %d + ] + }`, + a: []interface{}{"world", 42}, + want: ` +{ + "hello": "world", + "foo": [ + 42 + ] +}`, + }, + { + name: "multi-line space indented with blank lines", + s: ` + { + "hello": "%s", + "foo": [ + + %d + + ] + }`, + a: []interface{}{"world", 42}, + want: ` +{ + "hello": "world", + "foo": [ + + 42 + + ] +}`, + }, + { + name: "multi-line tab indented with blank lines", + s: ` + { + "hello": "%s", + "foo": [ + + %d + + ] + }`, + a: []interface{}{"world", 42}, + want: ` +{ + "hello": "world", + "foo": [ + + 42 + + ] +}`, + }, + { + name: "multi-line space indented with random indentation", + s: ` + hello + %s + foo + %d`, + a: []interface{}{"world", 42}, + want: ` + hello +world + foo + 42`, + }, + { + name: "multi-line tab indented with random indentation", + s: ` + hello + %s + foo + %d`, + a: []interface{}{"world", 42}, + want: ` + hello +world + foo + 42`, + }, +} + +func TestBytes(t *testing.T) { + for _, tt := range stringTestCases { + t.Run(tt.name, func(t *testing.T) { + got := Bytes([]byte(tt.s)) + + assert.Equal(t, []byte(tt.want), got) + }) + } +} + +func TestString(t *testing.T) { + for _, tt := range stringTestCases { + t.Run(tt.name, func(t *testing.T) { + got := String(tt.s) + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestStringf(t *testing.T) { + for _, tt := range stringfTestCases { + t.Run(tt.name, func(t *testing.T) { + got := Stringf(tt.s, tt.a...) + + assert.Equal(t, tt.want, got) + }) + } +} + +func BenchmarkBytes(b *testing.B) { + for _, tt := range stringTestCases { + b.Run(tt.name, func(b *testing.B) { + input := []byte(tt.s) + + for i := 0; i < b.N; i++ { + Bytes(input) + } + }) + } +} + +func BenchmarkString(b *testing.B) { + for _, tt := range stringTestCases { + b.Run(tt.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + String(tt.s) + } + }) + } +} From d481444f94ce1ba7aebd6759a32967aceada534a Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Thu, 26 Nov 2020 03:09:06 +0000 Subject: [PATCH 2/2] ci(github): add CI workflow for GitHub Actions --- .github/workflows/ci.yml | 139 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2a2dcce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,139 @@ +--- +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.31 + 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/master' + 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 + 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 tests + run: make test + env: + VERBOSE: "true" + + benchmark-store: + name: Store benchmarks + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + 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.GH_PUSH_TOKEN }} + comment-on-alert: true + auto-push: true