diff --git a/.github/workflows/go-fmt.yml b/.github/workflows/go-fmt.yml index 69fea98..f588490 100644 --- a/.github/workflows/go-fmt.yml +++ b/.github/workflows/go-fmt.yml @@ -24,11 +24,11 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v4 with: - go-version: ">=1.18.0" + go-version: ">=1.21.0" - name: Install goimports run: go install golang.org/x/tools/cmd/goimports@latest diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a443303..cd55370 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -24,11 +24,11 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: 1.18 + go-version: '>=1.21.0' - name: Build run: go build -v ./... @@ -37,4 +37,4 @@ jobs: run: go test -race -coverprofile=cover.out -v ./... - name: Post Coverage - uses: codecov/codecov-action@v2 \ No newline at end of file + uses: codecov/codecov-action@v3 \ No newline at end of file diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 74345ca..369379d 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -31,15 +31,16 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/setup-go@v3 + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v4 with: - go-version: '1.20' - - uses: actions/checkout@v3 + go-version: '>=1.21.0' - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: v1.52.2 + version: v1.54 # Optional: working directory, useful for monorepos # working-directory: somedir diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 5588ed9..dad8781 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -24,11 +24,11 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: 1.18 + go-version: '>=1.21.0' - name: Test run: sudo sh ./script/integrate_test.sh \ No newline at end of file diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 51f70a8..b77de53 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v4 + - uses: actions/stale@v8 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'This issue is inactive for a long time.' diff --git a/.gitignore b/.gitignore index 8d07b1b..2e35913 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,7 @@ # vendor/ # Go workspace file -go.work .idea +.vscode/ +**/.DS_Store +test.db \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fb9bba3 --- /dev/null +++ b/Makefile @@ -0,0 +1,65 @@ +APP_PATH:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) +SCRIPTS_PATH:=$(APP_PATH)/scripts + +.PHONY: setup +setup: + @echo "初始化开发环境......" + @find "$(SCRIPTS_PATH)" -type f -name '*.sh' -exec chmod +x {} \; + @bash $(SCRIPTS_PATH)/setup/setup.sh + @$(MAKE) tidy + +# 依赖清理 +.PHONY: tidy +tidy: + @go mod tidy + +# 代码风格 +.PHONY: fmt +fmt: + @goimports -l -w $$(find . -type f -name '*.go' -not -path "./.idea/*" -not -path "./cmd/monolithic/ioc/wire_gen.go") + @gofumpt -l -w $$(find . -type f -name '*.go' -not -path "./.idea/*" -not -path "./cmd/monolithic/ioc/wire_gen.go") + +# 静态扫描 +.PHONY: lint +lint: + @golangci-lint run -c $(SCRIPTS_PATH)/lint/.golangci.yaml ./... + +# 单元测试 +.PHONY: ut +ut: + @go test -race -cover -coverprofile=unit.out -failfast -shuffle=on ./... + +# 集成测试 +.PHONY: it +it: + @make dev_3rd_down + @make dev_3rd_up + @go test -tags=integration -race -cover -coverprofile=integration.out -failfast -shuffle=on ./... + @make dev_3rd_down + +# 端到端测试 +.PHONY: e2e +e2e: + @make dev_3rd_down + @make dev_3rd_up + @go test -tags=e2e -race -cover -coverprofile=e2e.out -failfast -shuffle=on ./... + @make dev_3rd_down + +# 启动本地研发 docker 依赖 +.PHONY: dev_3rd_up +dev_3rd_up: + @docker compose -f ./scripts/deploy/dev-compose.yaml up -d + +.PHONY: dev_3rd_down +dev_3rd_down: + @docker compose -f ./scripts/deploy/dev-compose.yaml down -v + +.PHONY: check +check: + @echo "\n>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 检查阶段 <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<\n" + @echo "整理项目依赖中......" + @$(MAKE) tidy + @echo "代码风格检查中......" + @$(MAKE) fmt + @echo "代码静态扫描中......" + @$(MAKE) lint \ No newline at end of file diff --git a/go.mod b/go.mod index a43fa3c..3862786 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/ecodeclub/mq-api go 1.21.0 require ( - github.com/ecodeclub/ekit v0.0.7 + github.com/ecodeclub/ekit v0.0.8 github.com/stretchr/testify v1.8.4 ) diff --git a/go.sum b/go.sum index cde54d0..51b55aa 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ecodeclub/ekit v0.0.7 h1:6e3p4FQToZPvnsHSKRCTcDo+vYcr8yChV78NeCOcEp0= -github.com/ecodeclub/ekit v0.0.7/go.mod h1:q/cMifDy7CygsCz9NZNgFS6lksEo5tWxsb7RjMoZv00= +github.com/ecodeclub/ekit v0.0.8 h1:861Aot0GvD5ueREEYDVYc1oIhDuFyg6MTxIyiOa4Pvw= +github.com/ecodeclub/ekit v0.0.8/go.mod h1:OqTojKeKFTxeeAAUwNIPKu339SRkX6KAuoK/8A5BCEs= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= diff --git a/memory/mq.go b/memory/mq.go index c5c4e40..d981444 100644 --- a/memory/mq.go +++ b/memory/mq.go @@ -16,9 +16,10 @@ package memory import ( "context" + "sync" + "github.com/ecodeclub/ekit/syncx" "github.com/ecodeclub/mq-api" - "sync" ) type Topic struct { @@ -30,12 +31,6 @@ type Topic struct { type topicOption func(topic *Topic) -func WithProducerChannelSize(size int) topicOption { - return func(topic *Topic) { - topic.produceChan = make(chan *mq.Message, size) - } -} - func (t *Topic) NewConsumer(size int) mq.Consumer { consumerCh := make(chan *mq.Message, size) t.lock.Lock() @@ -100,7 +95,8 @@ func NewMq() mq.MQ { func (m *Mq) Consumer(topic string) mq.Consumer { tp, _ := m.topics.LoadOrStore(topic, NewTopic(topic)) - return tp.NewConsumer(10) + const size = 10 + return tp.NewConsumer(size) } func (m *Mq) Producer(topic string) mq.Producer { @@ -109,9 +105,10 @@ func (m *Mq) Producer(topic string) mq.Producer { } func NewTopic(name string, opts ...topicOption) *Topic { + const i = 1000 t := &Topic{ Name: name, - produceChan: make(chan *mq.Message, 1000), + produceChan: make(chan *mq.Message, i), } for _, opt := range opts { opt(t) diff --git a/memory/mq_test.go b/memory/mq_test.go index 59b891f..feb1001 100644 --- a/memory/mq_test.go +++ b/memory/mq_test.go @@ -16,12 +16,13 @@ package memory import ( "context" - "github.com/ecodeclub/mq-api" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" "sync" "testing" "time" + + "github.com/ecodeclub/mq-api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" ) type MemoryMqTestSuite struct { @@ -34,7 +35,7 @@ func (m *MemoryMqTestSuite) SetupSuite() { } func (m *MemoryMqTestSuite) TestMq() { - testcases := []struct { + testcases := []*struct { name string consumers []mq.Consumer producers []mq.Producer @@ -101,11 +102,11 @@ func (m *MemoryMqTestSuite) TestMq() { for _, a := range ansList { assert.Equal(t, tc.wantValue, a) } - }) } } func TestMq(t *testing.T) { + t.Parallel() suite.Run(t, &MemoryMqTestSuite{}) } diff --git a/scripts/cicd/3rd-dependency-check.sh b/scripts/cicd/3rd-dependency-check.sh new file mode 100755 index 0000000..596889d --- /dev/null +++ b/scripts/cicd/3rd-dependency-check.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +output=$(make tidy) +if [ -n "$output" ]; then + echo "错误: 请在本地运行'make tidy'命令,确认无误后再提交." >&2 + exit 1 +fi \ No newline at end of file diff --git a/scripts/cicd/code-static-check.sh b/scripts/cicd/code-static-check.sh new file mode 100755 index 0000000..7d63340 --- /dev/null +++ b/scripts/cicd/code-static-check.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +output=$(make lint) +if [ -n "$output" ]; then + echo "错误: 请在本地运行'make lint'命令,确认无误后再提交." >&2 + exit 1 +fi \ No newline at end of file diff --git a/scripts/cicd/code-style-check.sh b/scripts/cicd/code-style-check.sh new file mode 100755 index 0000000..8c982af --- /dev/null +++ b/scripts/cicd/code-style-check.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +output=$(make fmt) +if [ -n "$output" ]; then + echo >&2 "错误: 请在本地运行'make fmt'命令,确认无误后再提交." + exit 1 +fi \ No newline at end of file diff --git a/scripts/cicd/end-to-end-testing.sh b/scripts/cicd/end-to-end-testing.sh new file mode 100755 index 0000000..7e64d53 --- /dev/null +++ b/scripts/cicd/end-to-end-testing.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +echo "运行端到端测试中......" +make e2e +if [ $? -ne 0 ]; then + echo "错误: 请在本地运行'make e2e'命令,确认测试全部通过后再提交." >&2 + exit 1 +fi diff --git a/scripts/cicd/integration-testing.sh b/scripts/cicd/integration-testing.sh new file mode 100755 index 0000000..d33aa31 --- /dev/null +++ b/scripts/cicd/integration-testing.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +echo "运行集成测试中......" +make it +if [ $? -ne 0 ]; then + echo "错误: 请在本地运行'make it'命令,确认测试全部通过后再提交." >&2 + exit 1 +fi \ No newline at end of file diff --git a/scripts/cicd/unit-testing.sh b/scripts/cicd/unit-testing.sh new file mode 100755 index 0000000..e679378 --- /dev/null +++ b/scripts/cicd/unit-testing.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +echo "运行单元测试中......" +make ut +if [ $? -ne 0 ]; then + echo "错误: 请在本地运行'make ut'命令,确认测试全部通过后再提交." >&2 + exit 1 +fi \ No newline at end of file diff --git a/scripts/deploy/dev-compose.yaml b/scripts/deploy/dev-compose.yaml new file mode 100644 index 0000000..d0a4563 --- /dev/null +++ b/scripts/deploy/dev-compose.yaml @@ -0,0 +1,21 @@ +version: '3' +services: + zookeeper: + image: 'bitnami/zookeeper:latest' + ports: + - '2181:2181' + environment: + - ALLOW_ANONYMOUS_LOGIN=yes + kafka: + image: 'bitnami/kafka:3.5.1' + ports: + - '9092:9092' + environment: + - KAFKA_BROKER_ID=1 + - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092 + - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://127.0.0.1:9092 + - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181 + - ALLOW_PLAINTEXT_LISTENER=yes + - KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true + depends_on: + - zookeeper \ No newline at end of file diff --git a/scripts/lint/.golangci.yaml b/scripts/lint/.golangci.yaml new file mode 100644 index 0000000..0f018df --- /dev/null +++ b/scripts/lint/.golangci.yaml @@ -0,0 +1,230 @@ +# All available settings of specific linters. +linters-settings: + cyclop: + max-complexity: 15 + errcheck: + check-type-assertions: true + goconst: + min-len: 2 + min-occurrences: 3 + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + settings: + hugeParam: + # Size in bytes that makes the warning trigger. + # Default: 80 + # 性能瓶颈不在这里 + sizeThreshold: 1024 + govet: + check-shadowing: true + nolintlint: + require-explanation: true + require-specific: true + exhaustive: + check-generated: false + default-signifies-exhaustive: false + funlen: + lines: 100 + statements: 40 + +# https://olegk.dev/go-linters-configuration-the-right-version +linters: + # Set to true runs only fast linters. + # Good option for 'lint on save', pre-commit hook or CI. + fast: false + # Disable all linters. + # Default: false + disable-all: true + # Enable specific linter + # https://golangci-lint.run/usage/linters/#enabled-by-default + enable: + # Check for pass []any as any in variadic func(...any). + # Rare case but saved me from debugging a few times. + - asasalint + + # I prefer plane ASCII identifiers. + # Symbol `∆` instead of `delta` looks cool but no thanks. + - asciicheck + + # Checks for dangerous unicode character sequences. + # Super rare but why not to be a bit paranoid? + - bidichk + + # Checks whether HTTP response body is closed successfully. + - bodyclose + + # Check whether the function uses a non-inherited context. + - contextcheck + + # Checks function and package cyclomatic complexity. + # + # Cyclomatic complexity is a measurement, not a goal. + # (c) Bryan C. Mills / https://github.com/bcmills + - cyclop + + # Check for two durations multiplied together. + - durationcheck + + # Tool for code clone detection. + - dupl + + # Forces to not skip error check. + - errcheck + + # Checks `Err-` prefix for var and `-Error` suffix for error type. + - errname + + # Suggests to use `%w` for error-wrapping. + - errorlint + + # Checks for pointers to enclosing loop variables. + - exportloopref + + - exhaustive + + - funlen + + # Reports magic consts. Might be noisy but still good. + - gomnd + + # Same as `cyclop` linter (see above) + - gocognit + - goconst + - gocyclo + + # As you already know I'm a co-author. It would be strange to not use + # one of my warmly loved projects. + - gocritic + + # Forces to put `.` at the end of the comment. Code is poetry. +# - godot + + # Might not be that important, but I prefer to keep all of them. + # `gofumpt` is amazing, kudos to Daniel Marti https://github.com/mvdan/gofumpt + - gofmt + - gofumpt + - goimports + + # Allow or ban replace directives in go.mod + # or force explanation for retract directives. + - gomoddirectives + + # Powerful security-oriented linter. But requires some time to + # configure it properly, see https://github.com/securego/gosec#available-rules + - gosec + + # Linter that specializes in simplifying code. + - gosimple + + # Official Go tool. Must have. + - govet + + # Detects when assignments to existing variables are not used + # Last week I caught a bug with it. + - ineffassign + + # Fix all the misspells, amazing thing. + - misspell + + # Maintainability index of each function, subjective. + - maintidx + + # Finds naked/bare returns and requires change them. + - nakedret + + # Both require a bit more explicit returns. + - nilerr + - nilnil + + # Finds sending HTTP request without context.Context. + - noctx + + # Forces comment why another check is disabled. + # Better not to have //nolint: at all ;) + - nolintlint + + # Finds slices that could potentially be pre-allocated. + # Small performance win + cleaner code. + - prealloc + + # Finds shadowing of Go's predeclared identifiers. + # I hear a lot of complaints from junior developers. + # But after some time they find it very useful. + - predeclared + + # Lint your Prometheus metrics name. + - promlinter + + - paralleltest + + # Checks that package variables are not reassigned. + # Super rare case but can catch bad things (like `io.EOF = nil`) + - reassign + + # Drop-in replacement of `golint`. + - revive + + # Somewhat similar to `bodyclose` but for `database/sql` package. + - rowserrcheck + - sqlclosecheck + + # I have found that it's not the same as staticcheck binary :\ + - staticcheck + + # Is a replacement for `golint`, similar to `revive`. + - stylecheck + + # Check struct tags. + - tagliatelle + + # Test-related checks. All of them are good. + - tenv + - testableexamples + - thelper + - tparallel + + # Remove unnecessary type conversions, make code cleaner + - unconvert + + # Might be noisy but better to know what is unused + - unparam + + # Must have. Finds unused declarations. + - unused + + # Detect the possibility to use variables/constants from stdlib. + - usestdlibvars + + # Finds wasted assignment statements. + - wastedassign + + # Forces you to use empty lines. Great if configured correctly. +# 对于换行之类的来说,wsl 的检测规则和我代码的分组形式冲突了 +# - wsl +# Options for analysis running. +issues: + # Excluding configuration per-path, per-linter, per-text and per-source + exclude-rules: + - path: _test\.go + linters: + - funlen + - dupl + - maintidx + - gocognit +run: + go: '1.21.0' + timeout: 10m + skip-dirs: + - .idea + - .git + - .github + - scripts + - api + - config + - deployment + issues-exit-code: 1 \ No newline at end of file diff --git a/.github/pre-commit b/scripts/setup/git/pre-commit old mode 100755 new mode 100644 similarity index 77% rename from .github/pre-commit rename to scripts/setup/git/pre-commit index cee85c5..b9de469 --- a/.github/pre-commit +++ b/scripts/setup/git/pre-commit @@ -20,12 +20,17 @@ # Pre-commit configuration -RESULT=$(make check) -printf "执行检查中...\n" +sh "$(pwd)/scripts/cicd/3rd-dependency-check.sh" +if [ $? -ne 0 ]; then + exit 1 +fi -if [ -n "$RESULT" ]; then - echo >&2 "[ERROR]: 有文件发生变更,请将变更文件添加到本次提交中" +sh "$(pwd)/scripts/cicd/code-style-check.sh" +if [ $? -ne 0 ]; then exit 1 fi -exit 0 +sh "$(pwd)/scripts/cicd/code-static-check.sh" +if [ $? -ne 0 ]; then + exit 1 +fi \ No newline at end of file diff --git a/.github/pre-push b/scripts/setup/git/pre-push similarity index 64% rename from .github/pre-push rename to scripts/setup/git/pre-push index 0208db7..43ad113 100644 --- a/.github/pre-push +++ b/scripts/setup/git/pre-push @@ -20,31 +20,18 @@ # This script does not handle file names that contain spaces. # Pre-push configuration -remote=$1 -url=$2 -echo >&2 "Try pushing $2 to $1" -TEST="go test ./... -race -cover -failfast" -LINTER="golangci-lint run" - -# Run test and return if failed -printf "Running go test..." -$TEST -RESULT=$? -if [ $RESULT -ne 0 ]; then - echo >&2 "$TEST" - echo >&2 "Check code to pass test." +sh "$(pwd)/scripts/cicd/unit-testing.sh" +if [ $? -ne 0 ]; then exit 1 fi -# Run linter and return if failed -printf "Running go linter..." -$LINTER -RESULT=$? -if [ $RESULT -ne 0 ]; then - echo >&2 "$LINTER" - echo >&2 "Check code to pass linter." +sh "$(pwd)/scripts/cicd/integration-testing.sh" +if [ $? -ne 0 ]; then exit 1 fi -exit 0 \ No newline at end of file +sh "$(pwd)/scripts/cicd/end-to-end-testing.sh" +if [ $? -ne 0 ]; then + exit 1 +fi \ No newline at end of file diff --git a/scripts/setup/setup.sh b/scripts/setup/setup.sh new file mode 100755 index 0000000..b24e0ac --- /dev/null +++ b/scripts/setup/setup.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Copyright 2021 ecodeclub +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Check Go installation +echo "检查 Go版本、Docker、Docker Compose V2......" +GO_VERSION="1.21" # Specify the required Go version +if ! command -v go >/dev/null || [[ ! "$(go version | awk '{print $3}')" == *"$GO_VERSION"* ]]; then + echo "Go $GO_VERSION 未安装或者版本不正确" + exit 1 # 退出并返回错误代码 +fi + +if ! command -v docker >/dev/null; then + echo "Docker 未安装" + exit 1 # Exit with an error code +fi + +if ! command -v docker compose >/dev/null; then + echo "Docker Compose V2未安装" + exit 1 # Exit with an error code +fi + +DIR="$(cd "$(dirname "$0")" && pwd)" +SOURCE_COMMIT=$DIR/git/pre-commit +TARGET_COMMIT=.git/hooks/pre-commit +SOURCE_PUSH=$DIR/git/pre-push +TARGET_PUSH=.git/hooks/pre-push + +# copy pre-commit file if not exist. +echo "设置 git pre-commit hooks..." +cp "$SOURCE_COMMIT" $TARGET_COMMIT + +# copy pre-push file if not exist. +echo "设置 git pre-push hooks..." +cp "$SOURCE_PUSH" $TARGET_PUSH + +# add permission to TARGET_PUSH and TARGET_COMMIT file. +test -x $TARGET_PUSH || chmod +x $TARGET_PUSH +test -x $TARGET_COMMIT || chmod +x $TARGET_COMMIT + +echo "安装 golangci-lint......" +go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.54.2 + +echo "安装 goimports......" +go install golang.org/x/tools/cmd/goimports@latest + +echo "安装 gofumpt......" +go install mvdan.cc/gofumpt@latest \ No newline at end of file