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 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) + } + }) + } +}