├── .dockerignore ├── .editorconfig ├── .gitignore ├── .gitlab-ci.yml ├── .golangci.yml ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── eclint │ └── main.go ├── definition.go ├── definition_test.go ├── doc.go ├── files.go ├── files_test.go ├── fix.go ├── fix_test.go ├── go.mod ├── go.sum ├── integration_test.go ├── lint.go ├── lint_test.go ├── option.go ├── print.go ├── print_test.go ├── probes.go ├── probes_test.go ├── scanner.go ├── scanner_test.go ├── sonar-project.properties ├── testdata ├── block_comments │ ├── .editorconfig │ ├── a │ ├── b │ └── c ├── charset │ ├── .editorconfig │ ├── ascii.txt │ ├── ascii2.txt │ ├── iso-8859-1.txt │ ├── utf8-bom.txt │ └── utf8.txt ├── images │ ├── .editorconfig │ ├── edcon_tool.pdf │ ├── edcon_tool.png │ └── hello.txt.gz ├── insert_final_newline │ ├── .editorconfig │ ├── no_final_newline.md │ ├── no_final_newline.txt │ ├── with_final_newline.md │ └── with_final_newline.txt ├── invalid │ └── .editorconfig ├── max_line_length │ ├── .editorconfig │ ├── a │ ├── b │ └── c └── simple │ ├── .editorconfig │ ├── empty │ └── .keep │ └── simple.txt ├── validators.go └── validators_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !eclint 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 125 11 | 12 | [*.md] 13 | max_line_length = off 14 | 15 | [{LICENSE,go.*}] 16 | max_line_length = unset 17 | 18 | [Dockerfile] 19 | indent_size = 4 20 | 21 | [{*.yml,*.yaml}] 22 | indent_size = 2 23 | max_line_length = unset 24 | 25 | [{*.go,go.*}] 26 | indent_style = tab 27 | 28 | [lint_test.go] 29 | eclint_indent_style = unset 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | eclint 2 | vendor 3 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: golang:1.20-bookworm 2 | 3 | variables: 4 | NANCY_VERSION: v1.0 5 | GIT_DEPTH: "0" 6 | 7 | stages: 8 | - test 9 | - lint 10 | - check 11 | - snapshot 12 | 13 | go test: 14 | stage: test 15 | script: 16 | - go test -v ./... 17 | - go test -v ./... 18 | -cover -covermode atomic 19 | -coverprofile coverage.out 20 | -json > test-report.json 21 | - go tool cover -func coverage.out 22 | - go list -u -m -json all > go-list.json 23 | artifacts: 24 | paths: 25 | - coverage.out 26 | - test-report.json 27 | - go-list.json 28 | 29 | eclint: 30 | stage: lint 31 | script: 32 | - go build -o eclint gitlab.com/greut/eclint/cmd/eclint 33 | - ./eclint -exclude "testdata/**/*" 34 | 35 | golangci-lint: 36 | stage: lint 37 | image: golangci/golangci-lint 38 | script: 39 | - golangci-lint run ./... 40 | - golangci-lint run -v ./... 41 | --issues-exit-code 0 42 | --out-format checkstyle > report.xml 43 | artifacts: 44 | paths: 45 | - report.xml 46 | 47 | nancy: 48 | stage: check 49 | image: sonatypecommunity/nancy:${NANCY_VERSION}-alpine 50 | needs: 51 | - go test 52 | script: 53 | - cat go-list.json | nancy --quiet 54 | 55 | sonarcloud check: 56 | stage: check 57 | allow_failure: true 58 | image: 59 | name: sonarsource/sonar-scanner-cli:latest 60 | entrypoint: [""] 61 | needs: 62 | - go test 63 | - golangci-lint 64 | script: 65 | - sonar-scanner 66 | -Dsonar.projectVersion=$(git describe --abbrev=0 --tags) 67 | -Dsonar.qualitygate.wait=true # this does nothing yet 68 | 69 | go-mod-outdated: 70 | stage: check 71 | allow_failure: true 72 | needs: 73 | - go test 74 | script: 75 | - go install github.com/psampaz/go-mod-outdated@latest 76 | - cat go-list.json | $GOPATH/bin/go-mod-outdated -update -direct -ci 77 | 78 | goreleaser snapshot: 79 | stage: check 80 | image: 81 | name: goreleaser/goreleaser 82 | entrypoint: [''] 83 | services: 84 | - docker:dind 85 | variables: 86 | DOCKER_HOST: tcp://docker:2375 87 | GIT_DEPTH: 0 88 | script: 89 | - goreleaser --snapshot --skip-sign 90 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | cyclop: 3 | max-complexity: 15 4 | package-average: 10 5 | golint: 6 | min-confidence: 0.3 7 | gocyclo: 8 | min-complexity: 30 9 | 10 | linters: 11 | enable-all: true 12 | disable: 13 | - deadcode 14 | - depguard 15 | - exhaustivestruct 16 | - exhaustruct 17 | - golint 18 | - gomnd 19 | - ifshort 20 | - interfacer 21 | - maligned 22 | - nosnakecase 23 | - scopelint 24 | - structcheck 25 | - varcheck 26 | - varnamelen 27 | fast: false 28 | 29 | issues: 30 | exclude-rules: 31 | - path: _test\.go 32 | linters: 33 | - funlen 34 | - goerr113 35 | - ifshort 36 | - paralleltest 37 | - testpackage 38 | - tparallel 39 | - path: main.go 40 | linters: 41 | - cyclop 42 | - wrapcheck 43 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: eclint 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | builds: 7 | - id: eclint 8 | binary: eclint 9 | main: cmd/eclint/main.go 10 | goos: 11 | - darwin 12 | - linux 13 | - windows 14 | goarch: 15 | - amd64 16 | env: 17 | - CGO_ENABLED=0 18 | archives: 19 | - name_template: >- 20 | {{- .ProjectName }}_ 21 | {{- title .Os }}_ 22 | {{- if eq .Arch "amd64" }}x86_64 23 | {{- else if eq .Arch "386" }}i386 24 | {{- else }}{{ .Arch }}{{ end }} 25 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 26 | dockers: 27 | - image_templates: 28 | - greut/eclint:latest 29 | - greut/eclint:{{ .Tag }} 30 | - greut/eclint:v{{ .Major }} 31 | - greut/eclint:v{{ .Major }}.{{ .Minor }} 32 | - quay.io/greut/eclint:latest 33 | - quay.io/greut/eclint:{{ .Tag }} 34 | - quay.io/greut/eclint:v{{ .Major }} 35 | - quay.io/greut/eclint:v{{ .Major }}.{{ .Minor }} 36 | - registry.gitlab.com/greut/eclint:latest 37 | - registry.gitlab.com/greut/eclint:{{ .Tag }} 38 | - registry.gitlab.com/greut/eclint:v{{ .Major }} 39 | - registry.gitlab.com/greut/eclint:v{{ .Major }}.{{ .Minor }} 40 | - ghcr.io/greut/eclint/cmd:latest 41 | - ghcr.io/greut/eclint/cmd:{{ .Tag }} 42 | - ghcr.io/greut/eclint/cmd:v{{ .Major }} 43 | - ghcr.io/greut/eclint/cmd:v{{ .Major }}.{{ .Minor }} 44 | goos: linux 45 | goarch: amd64 46 | ids: 47 | - eclint 48 | build_flag_templates: 49 | - "--pull" 50 | - "--label=org.opencontainers.image.created={{.Date}}" 51 | - "--label=org.opencontainers.image.name={{.ProjectName}}" 52 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 53 | - "--label=org.opencontainers.image.source={{.GitURL}}" 54 | - "--label=org.opencontainers.image.version={{.Version}}" 55 | source: 56 | enabled: true 57 | checksum: 58 | name_template: 'checksums.txt' 59 | signs: 60 | - id: default 61 | artifacts: checksum 62 | - id: source 63 | artifacts: source 64 | - id: minisign default 65 | artifacts: checksum 66 | signature: "${artifact}.minisig" 67 | cmd: minisign 68 | stdin: "\n" 69 | args: [-x, "${signature}", -Sm, "${artifact}", -P, "RWRP3/Z4+t+iZk1QU6zufn6vSDlvd76FLWhGCkt5kE7YqW3mOtSh7FvE", -t, "{{ .ProjectName }} {{ .Tag }}"] 70 | - id: minisign source 71 | artifacts: source 72 | signature: "${artifact}.minisig" 73 | cmd: minisign 74 | stdin: "\n" 75 | args: [-x, "${signature}", -Sm, "${artifact}", -P, "RWRP3/Z4+t+iZk1QU6zufn6vSDlvd76FLWhGCkt5kE7YqW3mOtSh7FvE", -t, "{{ .ProjectName }} {{ .Tag }}"] 76 | snapshot: 77 | name_template: "{{ .Tag }}-next" 78 | changelog: 79 | sort: asc 80 | filters: 81 | exclude: 82 | - '^docs:' 83 | - '^test:' 84 | release: 85 | gitlab: 86 | owner: greut 87 | name: eclint 88 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3 2 | 3 | # hadolint ignore=DL3018 4 | RUN apk --update --no-cache add \ 5 | git 6 | 7 | COPY eclint /usr/local/bin/ 8 | 9 | CMD ["eclint"] 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019-2023 Yoan Blanc 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eclint - EditorConfig linter ★ 2 | 3 | A faster alternative to the [JavaScript _eclint_](https://github.com/jedmao/eclint) written in Go. 4 | 5 | Tarballs are signed (`.minisig`) using the following public key: 6 | 7 | RWRP3/Z4+t+iZk1QU6zufn6vSDlvd76FLWhGCkt5kE7YqW3mOtSh7FvE 8 | 9 | Which can be verified using [minisig](https://jedisct1.github.io/minisign/) or [signify](https://github.com/aperezdc/signify). 10 | 11 | ## Installation 12 | 13 | - [Archlinux](https://aur.archlinux.org/packages/eclint/) 14 | - [Docker](https://hub.docker.com/r/greut/eclint) ([Quay.io](https://quay.io/repository/greut/eclint)) 15 | - [GitHub action](https://github.com/greut/eclint-action/) 16 | - [Manual installs](https://gitlab.com/greut/eclint/-/releases) 17 | 18 | ## Usage 19 | 20 | ``` 21 | $ go install gitlab.com/greut/eclint/cmd/eclint 22 | 23 | $ eclint -version 24 | ``` 25 | 26 | Excluding some files using the EditorConfig matcher 27 | 28 | ``` 29 | $ eclint -exclude "testdata/**/*" 30 | ``` 31 | 32 | ## Features 33 | 34 | - `charset` 35 | - `end_of_line` 36 | - `indent_size` 37 | - `indent_style` 38 | - `insert_final_newline` 39 | - `max_line_length` (when using tabs, specify the `tab_width` or `indent_size`) 40 | - by default, UTF-8 charset is assumed and multi-byte characters should be 41 | counted as one. However, combining characters won't. 42 | - `trim_trailing_whitespace` 43 | - [domain-specific properties][dsl] 44 | - `line_comment` 45 | - `block_comment_start`, `block_comment`, `block_comment_end` 46 | - minimal magic bytes detection (currently for PDF) 47 | 48 | ### More 49 | 50 | - when no path is given, it searches for files via `git ls-files` 51 | - `-exclude` to filter out some files 52 | - unset / alter properties via the `eclint_` prefix 53 | - [Docker images](https://hub.docker.com/r/greut/eclint) (also on Quay.io, GitHub and GitLab registries) 54 | - colored output (use `-color`: `never` to disable and `always` to skip detection) 55 | - `-summary` mode showing only the number of errors per file 56 | - only the first X errors are shown (use `-show_all_errors` to disable) 57 | - binary file detection (however quite basic) 58 | - `-fix` to modify files in place rather than showing the errors currently: 59 | - only basic `unix2dos`, `dos2unix` 60 | - space to tab and tab to space conversion 61 | - trailing whitespaces 62 | 63 | ## Missing features 64 | 65 | - `max_line_length` counting UTF-32 characters 66 | - more tests 67 | - etc. 68 | 69 | ## Thanks for their contributions 70 | 71 | - [Viktor Szépe](https://github.com/szepeviktor) 72 | - [Takuya Fukuju](https://github.com/chalkygames123) 73 | - [Nicolas Mohr](https://gitlab.com/nicmr) 74 | - [Zadkiel Aharonian](https://gitlab.com/zadkiel_aharonian_c4) 75 | 76 | ## Benchmarks 77 | 78 | **NB** benchmarks matter at feature parity (which is also hard to measure). 79 | 80 | The contenders are the following. 81 | 82 | - [editorconfig-checker](https://github.com/editorconfig-checker/editorconfig-checker), also in Go. 83 | - [eclint](https://github.com/jedmao/eclint), in Node. 84 | 85 | The methodology is to run the linter against some big repositories `time eclint -show_all_errors`. 86 | 87 | | Repository | `editorconfig-checker` | `jedmao/eclint` | `greut/eclint` | 88 | |---------------|------------------------|-----------------|----------------| 89 | | [Roslyn][] | 37s | 1m5s | **4s** | 90 | | [SaltStack][] | 7s | 1m9s | **<1s** | 91 | 92 | [Roslyn]: https://github.com/dotnet/roslyn 93 | [SaltStack]: https://github.com/saltstack/salt 94 | 95 | ### Profiling 96 | 97 | Two options: `-cpuprofile ` and `-memprofile `, will produce the appropriate _pprof_ files. 98 | 99 | ## Libraries and tools 100 | 101 | - [aurora](https://github.com/logrusorgru/aurora), colored output 102 | - [chardet](https://github.com/gogs/chardet), charset detection 103 | - [editorconfig-core-go](https://github.com/editorconfig/editorconfig-core-go), `.editorconfig` parsing 104 | - [go-colorable](https://github.com/mattn/go-colorable), colored output on Windows (too soon) 105 | - [go-mod-outdated](https://github.com/psampaz/go-mod-outdated) 106 | - [golangci-lint](https://github.com/golangci/golangci-lint), Go linters 107 | - [goreleaser](https://goreleaser.com/) 108 | - [klogr](https://github.com/kubernetes/klog/tree/master/klogr) 109 | - [nancy](https://github.com/sonatype-nexus-community/nancy) 110 | 111 | [dsl]: https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties#ideas-for-domain-specific-properties 112 | -------------------------------------------------------------------------------- /cmd/eclint/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "runtime" 9 | "runtime/pprof" 10 | "syscall" 11 | 12 | "github.com/editorconfig/editorconfig-core-go/v2" 13 | "github.com/go-logr/logr" 14 | "github.com/mattn/go-colorable" 15 | "gitlab.com/greut/eclint" 16 | "golang.org/x/term" 17 | "k8s.io/klog/v2" 18 | "k8s.io/klog/v2/klogr" 19 | ) 20 | 21 | var version = "dev" 22 | 23 | const ( 24 | overridePrefix = "eclint_" 25 | ) 26 | 27 | func main() { //nolint:funlen 28 | flagVersion := false 29 | color := "auto" 30 | cpuprofile := "" 31 | memprofile := "" 32 | 33 | // hack to ensure other deferrable are executed beforehand. 34 | retcode := 0 35 | 36 | defer func() { os.Exit(retcode) }() 37 | 38 | log := klogr.New() 39 | 40 | defer klog.Flush() 41 | 42 | opt := &eclint.Option{ 43 | Stdout: os.Stdout, 44 | ShowErrorQuantity: 10, 45 | IsTerminal: term.IsTerminal(int(syscall.Stdout)), //nolint:unconvert 46 | } 47 | 48 | if runtime.GOOS == "windows" { 49 | opt.Stdout = colorable.NewColorableStdout() 50 | } 51 | 52 | // Flags 53 | klog.InitFlags(nil) 54 | flag.BoolVar(&flagVersion, "version", false, "print the version number") 55 | flag.StringVar(&color, "color", color, `use color when printing; can be "always", "auto", or "never"`) 56 | flag.BoolVar(&opt.Summary, "summary", opt.Summary, "enable the summary view") 57 | flag.BoolVar(&opt.FixAllErrors, "fix", opt.FixAllErrors, "enable fixing instead of error reporting") 58 | flag.BoolVar( 59 | &opt.ShowAllErrors, 60 | "show_all_errors", 61 | opt.ShowAllErrors, 62 | fmt.Sprintf("display all errors for each file (otherwise %d are kept)", opt.ShowErrorQuantity), 63 | ) 64 | flag.IntVar( 65 | &opt.ShowErrorQuantity, 66 | "show_error_quantity", 67 | opt.ShowErrorQuantity, 68 | "display only the first n errors (0 means all)", 69 | ) 70 | flag.StringVar(&opt.Exclude, "exclude", opt.Exclude, "paths to exclude") 71 | flag.StringVar(&cpuprofile, "cpuprofile", cpuprofile, "write cpu profile to `file`") 72 | flag.StringVar(&memprofile, "memprofile", memprofile, "write mem profile to `file`") 73 | flag.Parse() 74 | 75 | if flagVersion { 76 | fmt.Fprintf(opt.Stdout, "eclint %s\n", version) 77 | 78 | return 79 | } 80 | 81 | switch color { 82 | case "always": 83 | opt.IsTerminal = true 84 | case "never": 85 | opt.NoColors = true 86 | } 87 | 88 | if opt.Summary { 89 | opt.ShowAllErrors = true 90 | } 91 | 92 | if opt.ShowAllErrors { 93 | opt.ShowErrorQuantity = 0 94 | } 95 | 96 | if opt.Exclude != "" { 97 | _, err := editorconfig.FnmatchCase(opt.Exclude, "dummy") 98 | if err != nil { 99 | log.Error(err, "exclude pattern failure", "exclude", opt.Exclude) 100 | flag.Usage() 101 | 102 | return 103 | } 104 | } 105 | 106 | if cpuprofile != "" { 107 | f, err := os.Create(cpuprofile) 108 | if err != nil { 109 | log.Error(err, "could not create CPU profile", "cpuprofile", cpuprofile) 110 | 111 | retcode = 1 112 | 113 | return 114 | } 115 | 116 | defer f.Close() 117 | 118 | if err := pprof.StartCPUProfile(f); err != nil { 119 | log.Error(err, "could not start CPU profile") 120 | } 121 | } 122 | 123 | ctx := logr.NewContext(context.Background(), log) 124 | 125 | c, err := processArgs(ctx, opt, flag.Args()) 126 | if err != nil { 127 | log.Error(err, "linting failure") 128 | 129 | retcode = 2 130 | 131 | return 132 | } 133 | 134 | if memprofile != "" { 135 | f, err := os.Create(memprofile) 136 | if err != nil { 137 | log.Error(err, "could not create memory profile", "memprofile", memprofile) 138 | } 139 | defer f.Close() 140 | 141 | runtime.GC() // get up-to-date statistics 142 | 143 | if err := pprof.WriteHeapProfile(f); err != nil { 144 | log.Error(err, "could not write memory profile") 145 | } 146 | } 147 | 148 | if cpuprofile != "" { 149 | pprof.StopCPUProfile() 150 | } 151 | 152 | if c > 0 { 153 | log.V(1).Info("some errors were found.", "count", c) 154 | 155 | retcode = 1 156 | } 157 | } 158 | 159 | func processArgs(ctx context.Context, opt *eclint.Option, args []string) (int, error) { //nolint:funlen,gocognit 160 | log := logr.FromContextOrDiscard(ctx) 161 | c := 0 162 | 163 | config := &editorconfig.Config{ 164 | Parser: editorconfig.NewCachedParser(), 165 | } 166 | 167 | fileChan, errChan := eclint.ListFilesContext(ctx, args...) 168 | 169 | for { 170 | select { 171 | case <-ctx.Done(): 172 | return 0, ctx.Err() 173 | 174 | case err, ok := <-errChan: 175 | if ok { 176 | log.Error(err, "cannot list files") 177 | 178 | return 0, err 179 | } 180 | 181 | case filename, ok := <-fileChan: 182 | if !ok { 183 | return c, nil 184 | } 185 | 186 | log := log.WithValues("filename", filename) 187 | 188 | // Skip excluded files 189 | if opt.Exclude != "" { 190 | ok, err := editorconfig.FnmatchCase(opt.Exclude, filename) 191 | if err != nil { 192 | log.Error(err, "exclude pattern failure", "exclude", opt.Exclude) 193 | 194 | return 0, err 195 | } 196 | 197 | if ok { 198 | continue 199 | } 200 | } 201 | 202 | def, err := config.Load(filename) 203 | if err != nil { 204 | log.Error(err, "cannot open file") 205 | 206 | return 0, err 207 | } 208 | 209 | err = eclint.OverrideDefinitionUsingPrefix(def, overridePrefix) 210 | if err != nil { 211 | log.Error(err, "overriding the definition failed", "prefix", overridePrefix) 212 | 213 | return 0, err 214 | } 215 | 216 | // Linting vs Fixing 217 | if !opt.FixAllErrors { 218 | errs := eclint.LintWithDefinition(ctx, def, filename) 219 | c += len(errs) 220 | 221 | if err := eclint.PrintErrors(ctx, opt, filename, errs); err != nil { 222 | log.Error(err, "print errors failure") 223 | 224 | return 0, err 225 | } 226 | } else { 227 | err := eclint.FixWithDefinition(ctx, def, filename) 228 | if err != nil { 229 | log.Error(err, "fixing errors failure") 230 | 231 | return 0, err 232 | } 233 | } 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /definition.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/editorconfig/editorconfig-core-go/v2" 10 | ) 11 | 12 | // ErrNotImplemented represents a missing feature. 13 | var ErrNotImplemented = errors.New("not implemented yet, PRs are welcome") 14 | 15 | // definition contains the fields that aren't native to EditorConfig.Definition. 16 | type definition struct { 17 | editorconfig.Definition 18 | BlockCommentStart []byte 19 | BlockComment []byte 20 | BlockCommentEnd []byte 21 | MaxLength int 22 | TabWidth int 23 | IndentSize int 24 | LastLine []byte 25 | LastIndex int 26 | InsideBlockComment bool 27 | } 28 | 29 | func newDefinition(d *editorconfig.Definition) (*definition, error) { //nolint:cyclop 30 | def := &definition{ 31 | Definition: *d, 32 | TabWidth: d.TabWidth, 33 | } 34 | 35 | if def.Charset == "utf-8-bom" { 36 | def.Charset = "utf-8 bom" 37 | } 38 | 39 | if d.IndentSize != "" && d.IndentSize != UnsetValue { 40 | is, err := strconv.Atoi(d.IndentSize) 41 | if err != nil { 42 | return nil, fmt.Errorf("cannot convert indentsize %q to int: %w", d.IndentSize, err) 43 | } 44 | 45 | def.IndentSize = is 46 | } 47 | 48 | if def.IndentStyle != "" && def.IndentStyle != UnsetValue { //nolint:nestif 49 | bs, ok := def.Raw["block_comment_start"] 50 | if ok && bs != "" && bs != UnsetValue { 51 | def.BlockCommentStart = []byte(bs) 52 | bc, ok := def.Raw["block_comment"] 53 | 54 | if ok && bc != "" && bs != UnsetValue { 55 | def.BlockComment = []byte(bc) 56 | } 57 | 58 | be, ok := def.Raw["block_comment_end"] 59 | if !ok || be == "" || be == UnsetValue { 60 | return nil, fmt.Errorf( 61 | "%w: .editorconfig: block_comment_end was expected, none were found", 62 | ErrConfiguration, 63 | ) 64 | } 65 | 66 | def.BlockCommentEnd = []byte(be) 67 | } 68 | } 69 | 70 | if mll, ok := def.Raw["max_line_length"]; ok && mll != "off" && mll != UnsetValue { 71 | ml, er := strconv.Atoi(mll) 72 | if er != nil || ml < 0 { 73 | return nil, fmt.Errorf( 74 | "%w: .editorconfig: max_line_length expected a non-negative number, got %q", 75 | ErrConfiguration, 76 | mll, 77 | ) 78 | } 79 | 80 | def.MaxLength = ml 81 | 82 | if def.TabWidth <= 0 { 83 | def.TabWidth = DefaultTabWidth 84 | } 85 | } 86 | 87 | return def, nil 88 | } 89 | 90 | // EOL returns the byte value of the given definition. 91 | func (def *definition) EOL() ([]byte, error) { 92 | switch def.EndOfLine { 93 | case editorconfig.EndOfLineCr: 94 | return []byte{cr}, nil 95 | case editorconfig.EndOfLineCrLf: 96 | return []byte{cr, lf}, nil 97 | case editorconfig.EndOfLineLf: 98 | return []byte{lf}, nil 99 | default: 100 | return nil, fmt.Errorf("%w: unsupported EndOfLine value %s", ErrConfiguration, def.EndOfLine) 101 | } 102 | } 103 | 104 | // OverrideDefinitionUsingPrefix is an helper that takes the prefixed values. 105 | // 106 | // It replaces those values into the nominal ones. That way a tool could a 107 | // different set of definition than the real editor would. 108 | func OverrideDefinitionUsingPrefix(def *editorconfig.Definition, prefix string) error { 109 | for k, v := range def.Raw { 110 | if strings.HasPrefix(k, prefix) { 111 | nk := k[len(prefix):] 112 | def.Raw[nk] = v 113 | 114 | switch nk { 115 | case "indent_style": 116 | def.IndentStyle = v 117 | case "indent_size": 118 | def.IndentSize = v 119 | case "charset": 120 | def.Charset = v 121 | case "end_of_line": 122 | def.EndOfLine = v 123 | case "tab_width": 124 | i, err := strconv.Atoi(v) 125 | if err != nil { 126 | return fmt.Errorf("tab_width cannot be set. %w", err) 127 | } 128 | 129 | def.TabWidth = i 130 | case "trim_trailing_whitespace": 131 | return fmt.Errorf("%v cannot be overridden: %w", nk, ErrNotImplemented) 132 | case "insert_final_newline": 133 | return fmt.Errorf("%v cannot be overridden: %w", nk, ErrNotImplemented) 134 | } 135 | } 136 | } 137 | 138 | return nil 139 | } 140 | -------------------------------------------------------------------------------- /definition_test.go: -------------------------------------------------------------------------------- 1 | package eclint_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/editorconfig/editorconfig-core-go/v2" 7 | "gitlab.com/greut/eclint" 8 | ) 9 | 10 | func TestOverridingUsingPrefix(t *testing.T) { 11 | def := &editorconfig.Definition{ 12 | Charset: "utf-8 bom", 13 | IndentStyle: "tab", 14 | IndentSize: "3", 15 | TabWidth: 3, 16 | } 17 | 18 | raw := make(map[string]string) 19 | raw["@_charset"] = "unset" 20 | raw["@_indent_style"] = "space" 21 | raw["@_indent_size"] = "4" 22 | raw["@_tab_width"] = "4" 23 | def.Raw = raw 24 | 25 | if err := eclint.OverrideDefinitionUsingPrefix(def, "@_"); err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | if def.Charset != "unset" { 30 | t.Errorf("charset not changed, got %q", def.Charset) 31 | } 32 | 33 | if def.IndentStyle != "space" { 34 | t.Errorf("indent_style not changed, got %q", def.IndentStyle) 35 | } 36 | 37 | if def.IndentSize != "4" { 38 | t.Errorf("indent_size not changed, got %q", def.IndentSize) 39 | } 40 | 41 | if def.TabWidth != 4 { 42 | t.Errorf("tab_width not changed, got %d", def.TabWidth) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package eclint is a set of linters to for the EditorConfig rules 2 | package eclint 3 | -------------------------------------------------------------------------------- /files.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io/fs" 9 | "os" 10 | "os/exec" 11 | 12 | "github.com/go-logr/logr" 13 | ) 14 | 15 | // ListFilesContext lists the files in an asynchronous fashion 16 | // 17 | // When its empty, it relies on `git ls-files` first, which 18 | // would fail if `git` is not present or the current working 19 | // directory is not managed by it. In that case, it work the 20 | // current working directory. 21 | // 22 | // When args are given, it recursively walks into them. 23 | func ListFilesContext(ctx context.Context, args ...string) (<-chan string, <-chan error) { 24 | if len(args) > 0 { 25 | return WalkContext(ctx, args...) 26 | } 27 | 28 | dir := "." 29 | 30 | log := logr.FromContextOrDiscard(ctx) 31 | 32 | log.V(3).Info("fallback to `git ls-files`", "dir", dir) 33 | 34 | return GitLsFilesContext(ctx, dir) 35 | } 36 | 37 | // WalkContext iterates on each path item recursively (asynchronously). 38 | // 39 | // Future work: use godirwalk. 40 | func WalkContext(ctx context.Context, paths ...string) (<-chan string, <-chan error) { 41 | filesChan := make(chan string, 128) 42 | errChan := make(chan error, 1) 43 | 44 | go func() { 45 | defer close(filesChan) 46 | defer close(errChan) 47 | 48 | for _, path := range paths { 49 | // shortcircuit files 50 | if fi, err := os.Stat(path); err == nil && !fi.IsDir() { 51 | filesChan <- path 52 | 53 | break 54 | } 55 | 56 | err := fs.WalkDir(os.DirFS(path), ".", func(filename string, _ fs.DirEntry, err error) error { 57 | if err != nil { 58 | return err 59 | } 60 | 61 | select { 62 | case filesChan <- filename: 63 | return nil 64 | case <-ctx.Done(): 65 | return fmt.Errorf("walking dir got interrupted: %w", ctx.Err()) 66 | } 67 | }) 68 | if err != nil { 69 | errChan <- err 70 | 71 | break 72 | } 73 | } 74 | }() 75 | 76 | return filesChan, errChan 77 | } 78 | 79 | // GitLsFilesContext returns the list of file base on what is in the git index (asynchronously). 80 | // 81 | // -z is mandatory as some repositories non-ASCII file names which creates 82 | // quoted and escaped file names. This method also returns directories for 83 | // any submodule there is. Submodule will be skipped afterwards and thus 84 | // not checked. 85 | func GitLsFilesContext(ctx context.Context, path string) (<-chan string, <-chan error) { 86 | filesChan := make(chan string, 128) 87 | errChan := make(chan error, 1) 88 | 89 | go func() { 90 | defer close(filesChan) 91 | defer close(errChan) 92 | 93 | output, err := exec.CommandContext(ctx, "git", "ls-files", "-z", path).Output() 94 | if err != nil { 95 | var e *exec.ExitError 96 | if ok := errors.As(err, &e); ok { 97 | if e.ExitCode() == 128 { 98 | err = fmt.Errorf("not a git repository: %w", e) 99 | } else { 100 | err = fmt.Errorf("git ls-files failed with %s: %w", e.Stderr, e) 101 | } 102 | } 103 | 104 | errChan <- err 105 | 106 | return 107 | } 108 | 109 | fs := bytes.Split(output, []byte{0}) 110 | // last line is empty 111 | for _, f := range fs[:len(fs)-1] { 112 | select { 113 | case filesChan <- string(f): 114 | // everything is good 115 | case <-ctx.Done(): 116 | return 117 | } 118 | } 119 | }() 120 | 121 | return filesChan, errChan 122 | } 123 | -------------------------------------------------------------------------------- /files_test.go: -------------------------------------------------------------------------------- 1 | package eclint_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "gitlab.com/greut/eclint" 10 | ) 11 | 12 | const ( 13 | // testdataSimple contains a sample editorconfig directory with 14 | // some errors. 15 | testdataSimple = "testdata/simple" 16 | ) 17 | 18 | func TestListFiles(t *testing.T) { 19 | d := testdataSimple 20 | 21 | fs := 0 22 | fsChan, errChan := eclint.ListFilesContext(context.TODO(), d) 23 | 24 | outer: 25 | for { 26 | select { 27 | case err, ok := <-errChan: 28 | if ok && err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | case _, ok := <-fsChan: 33 | if !ok { 34 | break outer 35 | } 36 | fs++ 37 | } 38 | } 39 | 40 | if fs != 5 { 41 | t.Errorf("%s should have five files, got %d", d, fs) 42 | } 43 | } 44 | 45 | func TestListFilesNoArgs(t *testing.T) { 46 | skipNoGit(t) 47 | 48 | d := testdataSimple 49 | 50 | cwd, err := os.Getwd() 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | 55 | defer func() { 56 | if err := os.Chdir(cwd); err != nil { 57 | t.Fatal(err) 58 | } 59 | }() 60 | 61 | err = os.Chdir(d) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | fs := 0 67 | fsChan, errChan := eclint.ListFilesContext(context.TODO()) 68 | outer: 69 | for { 70 | select { 71 | case err, ok := <-errChan: 72 | if ok && err != nil { 73 | t.Fatal(err) 74 | } 75 | case _, ok := <-fsChan: 76 | if !ok { 77 | break outer 78 | } 79 | fs++ 80 | } 81 | } 82 | 83 | if fs != 3 { 84 | t.Errorf("%s should have three files, got %d", d, fs) 85 | } 86 | } 87 | 88 | func TestListFilesNoGit(t *testing.T) { 89 | d := fmt.Sprintf("/tmp/eclint/%d", os.Getpid()) 90 | 91 | err := os.MkdirAll(d, 0o700) 92 | if err != nil { 93 | t.Fatal(err) 94 | } 95 | 96 | cwd, err := os.Getwd() 97 | if err != nil { 98 | t.Fatal(err) 99 | } 100 | 101 | defer func() { 102 | if err := os.Chdir(cwd); err != nil { 103 | t.Fatal(err) 104 | } 105 | }() 106 | 107 | err = os.Chdir(d) 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | 112 | _, errChan := eclint.ListFilesContext(context.TODO()) 113 | 114 | err, ok := <-errChan 115 | if !ok || err == nil { 116 | t.Errorf("an error was expected, got nothing") 117 | } 118 | 119 | fs := 0 120 | fsChan, errChan := eclint.ListFilesContext(context.TODO(), ".") 121 | 122 | outer: 123 | for { 124 | select { 125 | case err, ok := <-errChan: 126 | if ok && err != nil { 127 | t.Fatal(err) 128 | } 129 | case _, ok := <-fsChan: 130 | if !ok { 131 | break outer 132 | } 133 | fs++ 134 | } 135 | } 136 | 137 | if fs != 1 { 138 | t.Errorf("%s should have one file, got %d", d, fs) 139 | } 140 | } 141 | 142 | func TestWalk(t *testing.T) { 143 | d := testdataSimple 144 | 145 | fs := 0 146 | fsChan, errChan := eclint.WalkContext(context.TODO(), d) 147 | 148 | outer: 149 | for { 150 | select { 151 | case err, ok := <-errChan: 152 | if ok && err != nil { 153 | t.Fatal(err) 154 | } 155 | case _, ok := <-fsChan: 156 | if !ok { 157 | break outer 158 | } 159 | fs++ 160 | } 161 | } 162 | 163 | if fs != 5 { 164 | t.Errorf("%s should have five files, got %d", d, fs) 165 | } 166 | } 167 | 168 | func TestGitLsFiles(t *testing.T) { 169 | skipNoGit(t) 170 | 171 | d := testdataSimple 172 | 173 | fs := 0 174 | fsChan, errChan := eclint.GitLsFilesContext(context.TODO(), d) 175 | 176 | outer: 177 | for { 178 | select { 179 | case err, ok := <-errChan: 180 | if ok && err != nil { 181 | t.Fatal(err) 182 | } 183 | case _, ok := <-fsChan: 184 | if !ok { 185 | break outer 186 | } 187 | fs++ 188 | } 189 | } 190 | 191 | if fs != 3 { 192 | t.Errorf("%s should have three files, got %d", d, fs) 193 | } 194 | } 195 | 196 | func TestGitLsFilesFailure(t *testing.T) { 197 | skipNoGit(t) 198 | 199 | d := fmt.Sprintf("/tmp/eclint/%d", os.Getpid()) 200 | 201 | err := os.MkdirAll(d, 0o700) 202 | if err != nil { 203 | t.Fatal(err) 204 | } 205 | 206 | _, errChan := eclint.GitLsFilesContext(context.TODO(), d) 207 | 208 | if err := <-errChan; err == nil { 209 | t.Error("an error was expected") 210 | } 211 | } 212 | 213 | func skipNoGit(t *testing.T) { 214 | t.Helper() 215 | 216 | if _, err := os.Stat(".git"); os.IsNotExist(err) { 217 | t.Skip("skipping test requiring .git to be present") 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /fix.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "fmt" 8 | "io" 9 | "os" 10 | 11 | "github.com/editorconfig/editorconfig-core-go/v2" 12 | "github.com/go-logr/logr" 13 | ) 14 | 15 | // FixWithDefinition does the hard work of validating the given file. 16 | func FixWithDefinition(ctx context.Context, d *editorconfig.Definition, filename string) error { 17 | def, err := newDefinition(d) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | stat, err := os.Stat(filename) 23 | if err != nil { 24 | return fmt.Errorf("cannot stat %s. %w", filename, err) 25 | } 26 | 27 | log := logr.FromContextOrDiscard(ctx) 28 | 29 | if stat.IsDir() { 30 | log.V(2).Info("skipped directory") 31 | 32 | return nil 33 | } 34 | 35 | fileSize := stat.Size() 36 | mode := stat.Mode() 37 | 38 | r, fixed, err := fixWithFilename(ctx, def, filename, fileSize) 39 | if err != nil { 40 | return fmt.Errorf("cannot fix %s: %w", filename, err) 41 | } 42 | 43 | if !fixed { 44 | log.V(1).Info("no fixes to apply", "filename", filename) 45 | 46 | return nil 47 | } 48 | 49 | if r == nil { 50 | return nil 51 | } 52 | 53 | // XXX keep mode as is. 54 | fp, err := os.OpenFile(filename, os.O_WRONLY|os.O_TRUNC, mode) //nolint:nosnakecase 55 | if err != nil { 56 | return fmt.Errorf("cannot open %s using %s: %w", filename, mode, err) 57 | } 58 | defer fp.Close() 59 | 60 | n, err := io.Copy(fp, r) 61 | if err != nil { 62 | return fmt.Errorf("error copying file: %w", err) 63 | } 64 | 65 | log.V(1).Info("bytes written", "filename", filename, "total", n) 66 | 67 | return nil 68 | } 69 | 70 | func fixWithFilename(ctx context.Context, def *definition, filename string, fileSize int64) (io.Reader, bool, error) { 71 | fp, err := os.Open(filename) 72 | if err != nil { 73 | return nil, false, fmt.Errorf("cannot open %s. %w", filename, err) 74 | } 75 | 76 | defer fp.Close() 77 | 78 | r := bufio.NewReader(fp) 79 | 80 | ok, err := probeReadable(fp, r) 81 | if err != nil { 82 | return nil, false, fmt.Errorf("cannot read %s. %w", filename, err) 83 | } 84 | 85 | log := logr.FromContextOrDiscard(ctx) 86 | 87 | if !ok { 88 | log.V(2).Info("skipped unreadable or empty file") 89 | 90 | return nil, false, nil 91 | } 92 | 93 | charset, isBinary, err := ProbeCharsetOrBinary(ctx, r, def.Charset) 94 | if err != nil { 95 | return nil, false, err 96 | } 97 | 98 | if isBinary { 99 | log.V(2).Info("binary file detected and skipped") 100 | 101 | return nil, false, nil 102 | } 103 | 104 | log.V(2).Info("charset probed", "charset", charset) 105 | 106 | return fix(ctx, r, fileSize, charset, def) 107 | } 108 | 109 | func fix( //nolint:funlen,cyclop 110 | ctx context.Context, 111 | r io.Reader, 112 | fileSize int64, 113 | _ string, 114 | def *definition, 115 | ) (io.Reader, bool, error) { 116 | log := logr.FromContextOrDiscard(ctx) 117 | 118 | buf := bytes.NewBuffer([]byte{}) 119 | 120 | size := def.IndentSize 121 | if def.TabWidth != 0 { 122 | size = def.TabWidth 123 | } 124 | 125 | if size == 0 { 126 | // Indent size default == 2 127 | size = 2 128 | } 129 | 130 | var c []byte 131 | 132 | var x []byte 133 | 134 | switch def.IndentStyle { 135 | case SpaceValue: 136 | c = bytes.Repeat([]byte{space}, size) 137 | x = []byte{tab} 138 | case TabValue: 139 | c = []byte{tab} 140 | x = bytes.Repeat([]byte{space}, size) 141 | case "", UnsetValue: 142 | size = 0 143 | default: 144 | return nil, false, fmt.Errorf( 145 | "%w: %q is an invalid value of indent_style, want tab or space", 146 | ErrConfiguration, 147 | def.IndentStyle, 148 | ) 149 | } 150 | 151 | eol, err := def.EOL() 152 | if err != nil { 153 | return nil, false, fmt.Errorf("cannot get EOL: %w", err) 154 | } 155 | 156 | trimTrailingWhitespace := false 157 | if def.TrimTrailingWhitespace != nil { 158 | trimTrailingWhitespace = *def.TrimTrailingWhitespace 159 | } 160 | 161 | fixed := false 162 | errs := ReadLines(r, fileSize, func(index int, data []byte, isEOF bool) error { 163 | var f bool 164 | if size != 0 { 165 | data, f = fixTabAndSpacePrefix(data, c, x) 166 | fixed = fixed || f 167 | } 168 | 169 | if trimTrailingWhitespace { 170 | data, f = fixTrailingWhitespace(data) 171 | fixed = fixed || f 172 | } 173 | 174 | if def.EndOfLine != "" && !isEOF { 175 | data, f = fixEndOfLine(data, eol) 176 | fixed = fixed || f 177 | } 178 | 179 | _, err := buf.Write(data) 180 | if err != nil { 181 | return fmt.Errorf("error writing into buffer: %w", err) 182 | } 183 | 184 | log.V(2).Info("fix line", "index", index, "fixed", fixed) 185 | 186 | return nil 187 | }) 188 | 189 | if len(errs) != 0 { 190 | return nil, false, errs[0] 191 | } 192 | 193 | if def.InsertFinalNewline != nil { 194 | f := fixInsertFinalNewline(buf, *def.InsertFinalNewline, eol) 195 | fixed = fixed || f 196 | } 197 | 198 | return buf, fixed, nil 199 | } 200 | 201 | // fixEndOfLine replaces any non eol suffix by the given one. 202 | func fixEndOfLine(data []byte, eol []byte) ([]byte, bool) { 203 | fixed := false 204 | 205 | if !bytes.HasSuffix(data, eol) { 206 | fixed = true 207 | data = bytes.TrimRight(data, "\r\n") 208 | data = append(data, eol...) 209 | } 210 | 211 | return data, fixed 212 | } 213 | 214 | // fixTabAndSpacePrefix replaces any `x` by `c` in the given `data`. 215 | func fixTabAndSpacePrefix(data []byte, c []byte, x []byte) ([]byte, bool) { 216 | newData := make([]byte, 0, len(data)) 217 | 218 | fixed := false 219 | 220 | i := 0 221 | for i < len(data) { 222 | if bytes.HasPrefix(data[i:], c) { 223 | i += len(c) 224 | 225 | newData = append(newData, c...) 226 | 227 | continue 228 | } 229 | 230 | if bytes.HasPrefix(data[i:], x) { 231 | i += len(x) 232 | 233 | newData = append(newData, c...) 234 | 235 | fixed = true 236 | 237 | continue 238 | } 239 | 240 | return append(newData, data[i:]...), fixed 241 | } 242 | 243 | return data, fixed 244 | } 245 | 246 | // fixTrailingWhitespace replaces any whitespace or tab from the end of the line. 247 | func fixTrailingWhitespace(data []byte) ([]byte, bool) { 248 | i := len(data) - 1 249 | 250 | // u -> v is the range to clean 251 | u := len(data) 252 | 253 | v := u //nolint: ifshort 254 | 255 | outer: 256 | for i >= 0 { 257 | switch data[i] { 258 | case '\r', '\n': 259 | i-- 260 | u-- 261 | v-- 262 | case ' ', '\t': 263 | i-- 264 | u-- 265 | default: 266 | break outer 267 | } 268 | } 269 | 270 | // If u != v then the line has been fixed. 271 | fixed := u != v 272 | if fixed { 273 | data = append(data[:u], data[v:]...) 274 | } 275 | 276 | return data, fixed 277 | } 278 | 279 | // fixInsertFinalNewline modifies buf to fix the existence of a final newline. 280 | // Line endings are assumed to already be consistent within the buffer. 281 | // A nil buffer or an empty buffer is returned as is. 282 | func fixInsertFinalNewline(buf *bytes.Buffer, insertFinalNewline bool, endOfLine []byte) bool { 283 | fixed := false 284 | 285 | if buf == nil || buf.Len() == 0 { 286 | return fixed 287 | } 288 | 289 | if insertFinalNewline { 290 | if !bytes.HasSuffix(buf.Bytes(), endOfLine) { 291 | fixed = true 292 | 293 | buf.Write(endOfLine) 294 | } 295 | } else { 296 | for bytes.HasSuffix(buf.Bytes(), endOfLine) { 297 | fixed = true 298 | buf.Truncate(buf.Len() - len(endOfLine)) 299 | } 300 | } 301 | 302 | return fixed 303 | } 304 | -------------------------------------------------------------------------------- /fix_test.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "testing" 8 | 9 | "github.com/editorconfig/editorconfig-core-go/v2" 10 | "github.com/google/go-cmp/cmp" 11 | ) 12 | 13 | func TestFixEndOfLine(t *testing.T) { //nolint:gocognit 14 | tests := []struct { 15 | Name string 16 | Lines [][]byte 17 | }{ 18 | { 19 | Name: "a file with many lines", 20 | Lines: [][]byte{ 21 | []byte("A file"), 22 | []byte("With many lines"), 23 | }, 24 | }, 25 | { 26 | Name: "a file with many lines and a final newline", 27 | Lines: [][]byte{ 28 | []byte("A file"), 29 | []byte("With many lines"), 30 | []byte("and a final newline."), 31 | []byte(""), 32 | }, 33 | }, 34 | } 35 | 36 | ctx := context.TODO() 37 | 38 | for _, tc := range tests { 39 | tc := tc 40 | 41 | file := bytes.Join(tc.Lines, []byte("\n")) 42 | fileSize := int64(len(file)) 43 | 44 | // Test the nominal case 45 | t.Run(tc.Name, func(t *testing.T) { 46 | t.Parallel() 47 | 48 | def, err := newDefinition(&editorconfig.Definition{ 49 | EndOfLine: editorconfig.EndOfLineLf, 50 | }) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | 55 | r := bytes.NewReader(file) 56 | out, fixed, err := fix(ctx, r, fileSize, "utf-8", def) 57 | if err != nil { 58 | t.Fatalf("no errors where expected, got %s", err) 59 | } 60 | 61 | if fixed { 62 | t.Errorf("file should not have been fixed") 63 | } 64 | 65 | result, err := io.ReadAll(out) 66 | if err != nil { 67 | t.Fatalf("cannot read result %s", err) 68 | } 69 | 70 | if !cmp.Equal(file, result) { 71 | t.Errorf("diff %s", cmp.Diff(file, result)) 72 | } 73 | }) 74 | 75 | // Test the inverse 76 | t.Run(tc.Name, func(t *testing.T) { 77 | t.Parallel() 78 | 79 | def, err := newDefinition(&editorconfig.Definition{ 80 | EndOfLine: editorconfig.EndOfLineCrLf, 81 | }) 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | r := bytes.NewReader(file) 87 | out, fixed, err := fix(ctx, r, fileSize, "utf-8", def) 88 | if err != nil { 89 | t.Fatalf("no errors where expected, got %s", err) 90 | } 91 | 92 | if !fixed { 93 | t.Errorf("file should have been fixed") 94 | } 95 | 96 | result, err := io.ReadAll(out) 97 | if err != nil { 98 | t.Fatalf("cannot read result %s", err) 99 | } 100 | 101 | if cmp.Equal(file, result) { 102 | t.Errorf("no differences, the file was not fixed") 103 | } 104 | }) 105 | } 106 | } 107 | 108 | func TestFixIndentStyle(t *testing.T) { 109 | tests := []struct { 110 | Name string 111 | IndentSize string 112 | IndentStyle string 113 | File []byte 114 | }{ 115 | { 116 | Name: "space to tab", 117 | IndentStyle: "tab", 118 | IndentSize: "2", 119 | File: []byte("\t\t \tA line\n"), 120 | }, 121 | { 122 | Name: "tab to space", 123 | IndentStyle: "space", 124 | IndentSize: "2", 125 | File: []byte("\t\t \tA line\n"), 126 | }, 127 | } 128 | 129 | ctx := context.TODO() 130 | 131 | for _, tc := range tests { 132 | tc := tc 133 | 134 | fileSize := int64(len(tc.File)) 135 | 136 | t.Run(tc.Name, func(t *testing.T) { 137 | t.Parallel() 138 | 139 | def, err := newDefinition(&editorconfig.Definition{ 140 | EndOfLine: "lf", 141 | IndentStyle: tc.IndentStyle, 142 | IndentSize: tc.IndentSize, 143 | }) 144 | if err != nil { 145 | t.Fatal(err) 146 | } 147 | 148 | if err := indentStyle(tc.IndentStyle, def.IndentSize, tc.File); err == nil { 149 | t.Errorf("the initial file should fail") 150 | } 151 | 152 | r := bytes.NewReader(tc.File) 153 | out, _, err := fix(ctx, r, fileSize, "utf-8", def) 154 | if err != nil { 155 | t.Fatalf("no errors where expected, got %s", err) 156 | } 157 | 158 | result, err := io.ReadAll(out) 159 | if err != nil { 160 | t.Fatalf("cannot read result %s", err) 161 | } 162 | 163 | if cmp.Equal(tc.File, result) { 164 | t.Errorf("no changes!?") 165 | } 166 | 167 | if err := indentStyle(tc.IndentStyle, def.IndentSize, result); err != nil { 168 | t.Errorf("no errors were expected, got %s", err) 169 | } 170 | }) 171 | } 172 | } 173 | 174 | func TestFixTrimTrailingWhitespace(t *testing.T) { 175 | tests := []struct { 176 | Name string 177 | Lines [][]byte 178 | }{ 179 | { 180 | Name: "space", 181 | Lines: [][]byte{ 182 | []byte("A file"), 183 | []byte(" with spaces "), 184 | []byte(" at the end "), 185 | []byte(" "), 186 | }, 187 | }, 188 | { 189 | Name: "tabs", 190 | Lines: [][]byte{ 191 | []byte("A file"), 192 | []byte(" with tabs\t"), 193 | []byte(" at the end\t\t"), 194 | []byte("\t"), 195 | }, 196 | }, 197 | { 198 | Name: "tabs and spaces", 199 | Lines: [][]byte{ 200 | []byte("A file"), 201 | []byte(" with tabs\t\t "), 202 | []byte(" and spaces\t \t"), 203 | []byte(" at the end \t"), 204 | }, 205 | }, 206 | } 207 | 208 | for _, tc := range tests { 209 | tc := tc 210 | 211 | t.Run(tc.Name, func(t *testing.T) { 212 | t.Parallel() 213 | 214 | for _, l := range tc.Lines { 215 | m, _ := fixTrailingWhitespace(l) 216 | 217 | err := checkTrimTrailingWhitespace(m) 218 | if err != nil { 219 | t.Errorf("no errors were expected. %s", err) 220 | } 221 | } 222 | }) 223 | } 224 | } 225 | 226 | func TestFixInsertFinalNewline(t *testing.T) { 227 | eolVariants := [][]byte{ 228 | {cr}, 229 | {lf}, 230 | {cr, lf}, 231 | } 232 | 233 | insertFinalNewlineVariants := []bool{true, false} 234 | newlinesAtEOLVariants := []int{0, 1, 3} 235 | 236 | type Test struct { 237 | InsertFinalNewline bool 238 | File []byte 239 | EolVariant []byte 240 | NewlinesAtEOL int 241 | } 242 | 243 | tests := make([]Test, 0, 54) 244 | 245 | // single line tests 246 | singleLineFile := []byte(`A single line file.`) 247 | 248 | for _, eolVariant := range eolVariants { 249 | for _, insertFinalNewlineVariant := range insertFinalNewlineVariants { 250 | for newlinesAtEOL := range newlinesAtEOLVariants { 251 | file := singleLineFile 252 | for i := 0; i < newlinesAtEOL; i++ { 253 | file = append(file, eolVariant...) 254 | } 255 | 256 | tests = append(tests, 257 | Test{ 258 | InsertFinalNewline: insertFinalNewlineVariant, 259 | File: file, 260 | EolVariant: eolVariant, 261 | NewlinesAtEOL: newlinesAtEOL, 262 | }, 263 | ) 264 | } 265 | } 266 | } 267 | 268 | // multiline tests 269 | multilineComponents := [][]byte{[]byte(`A`), []byte(`multiline`), []byte(`file.`)} 270 | 271 | for _, eolVariant := range eolVariants { 272 | multilineFile := bytes.Join(multilineComponents, eolVariant) 273 | 274 | for _, insertFinalNewlineVariant := range insertFinalNewlineVariants { 275 | for newlinesAtEOL := range newlinesAtEOLVariants { 276 | file := multilineFile 277 | for i := 0; i < newlinesAtEOL; i++ { 278 | file = append(file, eolVariant...) 279 | } 280 | 281 | tests = append(tests, 282 | Test{ 283 | InsertFinalNewline: insertFinalNewlineVariant, 284 | File: file, 285 | EolVariant: eolVariant, 286 | NewlinesAtEOL: newlinesAtEOL, 287 | }, 288 | ) 289 | } 290 | } 291 | } 292 | 293 | // empty file tests 294 | emptyFile := []byte("") 295 | 296 | for _, eolVariant := range eolVariants { 297 | for _, insertFinalNewlineVariant := range insertFinalNewlineVariants { 298 | tests = append(tests, 299 | Test{ 300 | InsertFinalNewline: insertFinalNewlineVariant, 301 | File: emptyFile, 302 | EolVariant: eolVariant, 303 | }, 304 | ) 305 | } 306 | } 307 | 308 | for _, tc := range tests { 309 | tc := tc 310 | 311 | t.Run("TestFixInsertFinalNewline", func(t *testing.T) { 312 | t.Parallel() 313 | 314 | buf := bytes.Buffer{} 315 | buf.Write(tc.File) 316 | before := buf.Bytes() 317 | fixInsertFinalNewline(&buf, tc.InsertFinalNewline, tc.EolVariant) 318 | after := buf.Bytes() 319 | err := checkInsertFinalNewline(buf.Bytes(), tc.InsertFinalNewline) 320 | if err != nil { 321 | t.Logf("before: %q", string(before)) 322 | t.Logf("after: %q", string(after)) 323 | t.Errorf("encountered error %s with test configuration %+v", err, tc) 324 | } 325 | }) 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gitlab.com/greut/eclint 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/editorconfig/editorconfig-core-go/v2 v2.6.0 7 | github.com/go-logr/logr v1.2.4 8 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f 9 | github.com/google/go-cmp v0.6.0 10 | github.com/logrusorgru/aurora v2.0.3+incompatible 11 | github.com/mattn/go-colorable v0.1.13 12 | golang.org/x/term v0.13.0 13 | golang.org/x/text v0.13.0 14 | k8s.io/klog/v2 v2.100.1 15 | ) 16 | 17 | require ( 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/mattn/go-isatty v0.0.16 // indirect 20 | github.com/pmezard/go-difflib v1.0.0 // indirect 21 | golang.org/x/mod v0.12.0 // indirect 22 | golang.org/x/sys v0.13.0 // indirect 23 | gopkg.in/ini.v1 v1.67.0 // indirect 24 | gopkg.in/yaml.v3 v3.0.1 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/editorconfig/editorconfig-core-go/v2 v2.6.0 h1:5O8paxMLmi/5ONoKXzWNYxoSZU7+ITVbGcPga0IrzfE= 4 | github.com/editorconfig/editorconfig-core-go/v2 v2.6.0/go.mod h1:hdTKe+hwa3mMnMn4JUQziT+yc3pF+6EVmK2LPbLZthE= 5 | github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 6 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= 7 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 8 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= 9 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= 10 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 11 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 | github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= 13 | github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= 14 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 15 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 16 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 17 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 21 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= 22 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 23 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 24 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 25 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 26 | golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= 27 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 28 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 29 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 31 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 32 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 33 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 34 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg= 36 | k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= 37 | -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | package eclint_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "gitlab.com/greut/eclint" 9 | ) 10 | 11 | func TestLintSimple(t *testing.T) { 12 | ctx := context.TODO() 13 | 14 | for _, err := range eclint.Lint(ctx, "testdata/simple/simple.txt") { 15 | if err != nil { 16 | t.Errorf("no errors where expected, got %s", err) 17 | } 18 | } 19 | } 20 | 21 | func TestLintMissing(t *testing.T) { 22 | ctx := context.TODO() 23 | 24 | errs := eclint.Lint(ctx, "testdata/missing/file") 25 | if len(errs) == 0 { 26 | t.Error("an error was expected, got none") 27 | } 28 | 29 | for _, err := range errs { 30 | if err == nil { 31 | t.Error("an error was expected") 32 | } 33 | } 34 | } 35 | 36 | func TestLintInvalid(t *testing.T) { 37 | ctx := context.TODO() 38 | 39 | errs := eclint.Lint(ctx, "testdata/invalid/.editorconfig") 40 | if len(errs) == 0 { 41 | t.Error("an error was expected, got none") 42 | } 43 | 44 | for _, err := range errs { 45 | if err == nil { 46 | t.Error("an error was expected") 47 | } 48 | } 49 | } 50 | 51 | func TestBlockCommentValidSpec(t *testing.T) { 52 | ctx := context.TODO() 53 | 54 | for _, f := range []string{"a", "b"} { 55 | for _, err := range eclint.Lint(ctx, fmt.Sprintf("./testdata/block_comments/%s", f)) { 56 | if err != nil { 57 | t.Fatalf("no errors where expected, got %s", err) 58 | } 59 | } 60 | } 61 | } 62 | 63 | func TestBlockCommentInvalidSpec(t *testing.T) { 64 | ctx := context.TODO() 65 | 66 | for _, f := range []string{"c"} { 67 | errs := eclint.Lint(ctx, fmt.Sprintf("./testdata/block_comments/%s", f)) 68 | if len(errs) == 0 { 69 | t.Errorf("one error was expected, got none") 70 | } 71 | } 72 | } 73 | 74 | func TestLintCharset(t *testing.T) { 75 | ctx := context.TODO() 76 | 77 | for _, f := range []string{"ascii", "ascii2", "iso-8859-1", "utf8"} { 78 | for _, err := range eclint.Lint(ctx, fmt.Sprintf("./testdata/charset/%s.txt", f)) { 79 | if err != nil { 80 | t.Errorf("no errors where expected, got %s", err) 81 | } 82 | } 83 | } 84 | } 85 | 86 | func TestLintImages(t *testing.T) { 87 | ctx := context.TODO() 88 | 89 | for _, f := range []string{"edcon_tool.png", "edcon_tool.pdf", "hello.txt.gz"} { 90 | for _, err := range eclint.Lint(ctx, fmt.Sprintf("./testdata/images/%s", f)) { 91 | if err != nil { 92 | t.Fatalf("no errors where expected, got %s", err) 93 | } 94 | } 95 | } 96 | } 97 | 98 | func TestMaxLineLengthValidSpec(t *testing.T) { 99 | ctx := context.TODO() 100 | 101 | for _, f := range []string{"a", "b"} { 102 | for _, err := range eclint.Lint(ctx, fmt.Sprintf("./testdata/max_line_length/%s", f)) { 103 | if err != nil { 104 | t.Fatalf("no errors where expected, got %s", err) 105 | } 106 | } 107 | } 108 | } 109 | 110 | func TestMaxLineLengthInvalidSpec(t *testing.T) { 111 | ctx := context.TODO() 112 | 113 | for _, f := range []string{"c"} { 114 | errs := eclint.Lint(ctx, fmt.Sprintf("./testdata/max_line_length/%s", f)) 115 | if len(errs) == 0 { 116 | t.Errorf("one error was expected, got none") 117 | } 118 | } 119 | } 120 | 121 | func TestInsertFinalNewlineSpec(t *testing.T) { 122 | ctx := context.TODO() 123 | 124 | for _, f := range []string{"with_final_newline.txt", "no_final_newline.md"} { 125 | for _, err := range eclint.Lint(ctx, fmt.Sprintf("./testdata/insert_final_newline/%s", f)) { 126 | if err != nil { 127 | t.Fatalf("no errors where expected, got %s", err) 128 | } 129 | } 130 | } 131 | } 132 | 133 | func TestInsertFinalNewlineInvalidSpec(t *testing.T) { 134 | ctx := context.TODO() 135 | 136 | for _, f := range []string{"no_final_newline.txt", "with_final_newline.md"} { 137 | errs := eclint.Lint(ctx, fmt.Sprintf("./testdata/insert_final_newline/%s", f)) 138 | if len(errs) == 0 { 139 | t.Errorf("one error was expected, got none") 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /lint.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "os" 11 | 12 | "github.com/editorconfig/editorconfig-core-go/v2" 13 | "github.com/go-logr/logr" 14 | "golang.org/x/text/encoding" 15 | "golang.org/x/text/encoding/unicode" 16 | "golang.org/x/text/transform" 17 | ) 18 | 19 | // DefaultTabWidth sets the width of a tab used when counting the line length. 20 | const DefaultTabWidth = 8 21 | 22 | const ( 23 | // UnsetValue is the value equivalent to an empty / unset one. 24 | UnsetValue = "unset" 25 | // TabValue is the value representing tab indentation (the ugly one). 26 | TabValue = "tab" 27 | // SpaceValue is the value representing space indentation (the good one). 28 | SpaceValue = "space" 29 | // Utf8 is the ubiquitous character set. 30 | Utf8 = "utf-8" 31 | // Latin1 is the legacy 7-bits character set. 32 | Latin1 = "latin1" 33 | ) 34 | 35 | // Lint does the hard work of validating the given file. 36 | func Lint(ctx context.Context, filename string) []error { 37 | def, err := editorconfig.GetDefinitionForFilename(filename) 38 | if err != nil { 39 | return []error{fmt.Errorf("cannot open file %s. %w", filename, err)} 40 | } 41 | 42 | return LintWithDefinition(ctx, def, filename) 43 | } 44 | 45 | // LintWithDefinition does the hard work of validating the given file. 46 | func LintWithDefinition(ctx context.Context, d *editorconfig.Definition, filename string) []error { //nolint:funlen 47 | log := logr.FromContextOrDiscard(ctx) 48 | 49 | def, err := newDefinition(d) 50 | if err != nil { 51 | return []error{err} 52 | } 53 | 54 | stat, err := os.Stat(filename) 55 | if err != nil { 56 | return []error{fmt.Errorf("cannot stat %s. %w", filename, err)} 57 | } 58 | 59 | if stat.IsDir() { 60 | log.V(2).Info("skipped directory") 61 | 62 | return nil 63 | } 64 | 65 | fileSize := stat.Size() 66 | 67 | fp, err := os.Open(filename) 68 | if err != nil { 69 | return []error{fmt.Errorf("cannot open %s. %w", filename, err)} 70 | } 71 | 72 | defer fp.Close() 73 | 74 | r := bufio.NewReader(fp) 75 | 76 | ok, err := probeReadable(fp, r) 77 | if err != nil { 78 | return []error{fmt.Errorf("cannot read %s. %w", filename, err)} 79 | } 80 | 81 | if !ok { 82 | log.V(2).Info("skipped unreadable or empty file") 83 | 84 | return nil 85 | } 86 | 87 | charset, isBinary, err := ProbeCharsetOrBinary(ctx, r, def.Charset) 88 | if err != nil { 89 | return []error{err} 90 | } 91 | 92 | if isBinary { 93 | log.V(2).Info("binary file detected and skipped") 94 | 95 | return nil 96 | } 97 | 98 | log.V(2).Info("charset probed", "filename", filename, "charset", charset) 99 | 100 | var decoder *encoding.Decoder 101 | 102 | switch charset { 103 | case "utf-16be": 104 | decoder = unicode.UTF16(unicode.BigEndian, unicode.ExpectBOM).NewDecoder() 105 | case "utf-16le": 106 | decoder = unicode.UTF16(unicode.LittleEndian, unicode.ExpectBOM).NewDecoder() 107 | default: 108 | decoder = unicode.UTF8.NewDecoder() 109 | } 110 | 111 | t := transform.NewReader(r, unicode.BOMOverride(decoder)) 112 | errs := validate(ctx, t, fileSize, charset, def) 113 | 114 | // Enrich the errors with the filename 115 | for i, err := range errs { 116 | var ve ValidationError 117 | if ok := errors.As(err, &ve); ok { 118 | ve.Filename = filename 119 | errs[i] = ve 120 | } else if err != nil { 121 | errs[i] = err 122 | } 123 | } 124 | 125 | return errs 126 | } 127 | 128 | // validate is where the validations rules are applied. 129 | func validate( //nolint:cyclop,gocognit,funlen 130 | ctx context.Context, 131 | r io.Reader, 132 | fileSize int64, 133 | charset string, 134 | def *definition, 135 | ) []error { 136 | return ReadLines(r, fileSize, func(index int, data []byte, isEOF bool) error { 137 | var err error 138 | 139 | if ctx.Err() != nil { 140 | return fmt.Errorf("read lines got interrupted: %w", ctx.Err()) 141 | } 142 | 143 | if isEOF { 144 | if def.InsertFinalNewline != nil { 145 | err = checkInsertFinalNewline(data, *def.InsertFinalNewline) 146 | } 147 | } else { 148 | if def.EndOfLine != "" && def.EndOfLine != UnsetValue { 149 | err = endOfLine(def.EndOfLine, data) 150 | } 151 | } 152 | 153 | if err == nil && //nolint:nestif 154 | def.IndentStyle != "" && 155 | def.IndentStyle != UnsetValue && 156 | def.Definition.IndentSize != UnsetValue { 157 | err = indentStyle(def.IndentStyle, def.IndentSize, data) 158 | if err != nil && def.InsideBlockComment && def.BlockComment != nil { 159 | // The indentation may fail within a block comment. 160 | var ve ValidationError 161 | if ok := errors.As(err, &ve); ok { 162 | err = checkBlockComment(ve.Position, def.BlockComment, data) 163 | } 164 | } 165 | 166 | if def.InsideBlockComment && def.BlockCommentEnd != nil { 167 | def.InsideBlockComment = !isBlockCommentEnd(def.BlockCommentEnd, data) 168 | } 169 | 170 | if err == nil && !def.InsideBlockComment && def.BlockCommentStart != nil { 171 | def.InsideBlockComment = isBlockCommentStart(def.BlockCommentStart, data) 172 | } 173 | } 174 | 175 | if err == nil && def.TrimTrailingWhitespace != nil && *def.TrimTrailingWhitespace { 176 | err = checkTrimTrailingWhitespace(data) 177 | } 178 | 179 | if err == nil && def.MaxLength > 0 { 180 | // Remove any BOM from the first line. 181 | d := data 182 | if index == 0 && charset != "" { 183 | for _, bom := range [][]byte{utf8Bom} { 184 | if bytes.HasPrefix(data, bom) { 185 | d = data[len(utf8Bom):] 186 | 187 | break 188 | } 189 | } 190 | } 191 | err = MaxLineLength(def.MaxLength, def.TabWidth, d) 192 | } 193 | 194 | // Enrich the error with the line number 195 | var ve ValidationError 196 | if ok := errors.As(err, &ve); ok { 197 | ve.Line = data 198 | ve.Index = index 199 | 200 | return ve 201 | } 202 | 203 | return err 204 | }) 205 | } 206 | -------------------------------------------------------------------------------- /lint_test.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "testing" 7 | 8 | "github.com/editorconfig/editorconfig-core-go/v2" 9 | ) 10 | 11 | func TestInsertFinalNewline(t *testing.T) { 12 | tests := []struct { 13 | Name string 14 | InsertFinalNewline bool 15 | File []byte 16 | }{ 17 | { 18 | Name: "has final newline", 19 | InsertFinalNewline: true, 20 | File: []byte(`A file 21 | with a final newline. 22 | `), 23 | }, { 24 | Name: "has newline", 25 | InsertFinalNewline: false, 26 | File: []byte(`A file 27 | without a final newline.`), 28 | }, { 29 | Name: "empty file", 30 | InsertFinalNewline: true, 31 | File: []byte(""), 32 | }, 33 | } 34 | 35 | ctx := context.TODO() 36 | 37 | for _, tc := range tests { 38 | tc := tc 39 | 40 | // Test the nominal case 41 | t.Run(tc.Name, func(t *testing.T) { 42 | t.Parallel() 43 | 44 | def, err := newDefinition(&editorconfig.Definition{ 45 | InsertFinalNewline: &tc.InsertFinalNewline, 46 | }) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | r := bytes.NewReader(tc.File) 52 | for _, err := range validate(ctx, r, -1, "utf-8", def) { 53 | if err != nil { 54 | t.Errorf("no errors where expected, got %s", err) 55 | } 56 | } 57 | }) 58 | 59 | // Test the inverse 60 | t.Run(tc.Name, func(t *testing.T) { 61 | t.Parallel() 62 | 63 | insertFinalNewline := !tc.InsertFinalNewline 64 | def, err := newDefinition(&editorconfig.Definition{ 65 | InsertFinalNewline: &insertFinalNewline, 66 | }) 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | r := bytes.NewReader(tc.File) 72 | 73 | for _, err := range validate(ctx, r, -1, "utf-8", def) { 74 | if err == nil { 75 | t.Error("an error was expected") 76 | } 77 | } 78 | }) 79 | } 80 | } 81 | 82 | func TestBlockComment(t *testing.T) { 83 | tests := []struct { 84 | Name string 85 | BlockCommentStart string 86 | BlockComment string 87 | BlockCommentEnd string 88 | File []byte 89 | }{ 90 | { 91 | Name: "Java", 92 | BlockCommentStart: "/*", 93 | BlockComment: "*", 94 | BlockCommentEnd: "*/", 95 | File: []byte(` 96 | /** 97 | * 98 | */ 99 | public class ... {} 100 | `), 101 | }, 102 | } 103 | 104 | ctx := context.TODO() 105 | 106 | for _, tc := range tests { 107 | tc := tc 108 | 109 | // Test the nominal case 110 | t.Run(tc.Name, func(t *testing.T) { 111 | t.Parallel() 112 | 113 | def := &editorconfig.Definition{ 114 | EndOfLine: "lf", 115 | Charset: "utf-8", 116 | IndentStyle: "tab", 117 | } 118 | def.Raw = make(map[string]string) 119 | def.Raw["block_comment_start"] = tc.BlockCommentStart 120 | def.Raw["block_comment"] = tc.BlockComment 121 | def.Raw["block_comment_end"] = tc.BlockCommentEnd 122 | d, err := newDefinition(def) 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | 127 | r := bytes.NewReader(tc.File) 128 | for _, err := range validate(ctx, r, -1, "utf-8", d) { 129 | if err != nil { 130 | t.Errorf("no errors where expected, got %s", err) 131 | } 132 | } 133 | }) 134 | } 135 | } 136 | 137 | func TestBlockCommentFailure(t *testing.T) { 138 | tests := []struct { 139 | Name string 140 | BlockCommentStart string 141 | BlockComment string 142 | BlockCommentEnd string 143 | File []byte 144 | }{ 145 | { 146 | Name: "Java no block_comment_end", 147 | BlockCommentStart: "/*", 148 | BlockComment: "*", 149 | BlockCommentEnd: "", 150 | File: []byte(`Hello!`), 151 | }, 152 | } 153 | 154 | for _, tc := range tests { 155 | tc := tc 156 | 157 | // Test the nominal case 158 | t.Run(tc.Name, func(t *testing.T) { 159 | t.Parallel() 160 | 161 | def := &editorconfig.Definition{ 162 | IndentStyle: "tab", 163 | } 164 | def.Raw = make(map[string]string) 165 | def.Raw["block_comment_start"] = tc.BlockCommentStart 166 | def.Raw["block_comment"] = tc.BlockComment 167 | def.Raw["block_comment_end"] = tc.BlockCommentEnd 168 | 169 | _, err := newDefinition(def) 170 | if err == nil { 171 | t.Fatal("one error was expected, got none") 172 | } 173 | }) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // Option contains the environment of the program. 8 | // 9 | // When ShowErrorQuantity is 0, it will show all the errors. Use ShowAllErrors false to disable this. 10 | type Option struct { 11 | IsTerminal bool 12 | NoColors bool 13 | ShowAllErrors bool 14 | Summary bool 15 | FixAllErrors bool 16 | ShowErrorQuantity int 17 | Exclude string 18 | Stdout io.Writer 19 | } 20 | -------------------------------------------------------------------------------- /print.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "strconv" 9 | 10 | "github.com/go-logr/logr" 11 | "github.com/logrusorgru/aurora" 12 | ) 13 | 14 | // PrintErrors is the rich output of the program. 15 | func PrintErrors(ctx context.Context, opt *Option, filename string, errs []error) error { 16 | counter := 0 17 | 18 | log := logr.FromContextOrDiscard(ctx) 19 | stdout := opt.Stdout 20 | 21 | au := aurora.NewAurora(opt.IsTerminal && !opt.NoColors) 22 | 23 | for _, err := range errs { 24 | if err != nil { //nolint:nestif 25 | if counter == 0 && !opt.Summary { 26 | fmt.Fprintf(stdout, "%s:\n", au.Magenta(filename).Bold()) 27 | } 28 | 29 | var ve ValidationError 30 | if ok := errors.As(err, &ve); ok { 31 | log.V(4).Info("lint error", "error", ve) 32 | 33 | if !opt.Summary { 34 | vi := au.Green(strconv.Itoa(ve.Index + 1)).Bold() 35 | vp := au.Green(strconv.Itoa(ve.Position + 1)).Bold() 36 | fmt.Fprintf(stdout, "%s:%s: %s\n", vi, vp, ve.Message) 37 | 38 | l, err := errorAt(au, ve.Line, ve.Position) 39 | if err != nil { 40 | log.Error(err, "line formatting failure", "error", ve) 41 | 42 | return err 43 | } 44 | 45 | fmt.Fprintln(stdout, l) 46 | } 47 | } else { 48 | log.V(2).Info("lint error", "filename", filename, "error", err.Error()) 49 | fmt.Fprintln(stdout, err) 50 | } 51 | 52 | counter++ 53 | 54 | if opt.ShowErrorQuantity > 0 && counter >= opt.ShowErrorQuantity && len(errs) > counter { 55 | fmt.Fprintf( 56 | stdout, 57 | " ... skipping at most %s errors\n", 58 | au.BrightRed(strconv.Itoa(len(errs)-counter)), 59 | ) 60 | 61 | break 62 | } 63 | } 64 | } 65 | 66 | if counter > 0 { 67 | if !opt.Summary { 68 | fmt.Fprintln(stdout, "") 69 | } else { 70 | fmt.Fprintf(stdout, "%s: %d errors\n", au.Magenta(filename), counter) 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | 77 | // errorAt highlights the ValidationError position within the line. 78 | func errorAt(au aurora.Aurora, line []byte, position int) (string, error) { 79 | b := bytes.NewBuffer(make([]byte, len(line))) 80 | 81 | if position > len(line)-1 { 82 | position = len(line) - 1 83 | } 84 | 85 | for i := 0; i < position; i++ { 86 | if line[i] != cr && line[i] != lf { 87 | if err := b.WriteByte(line[i]); err != nil { 88 | return "", fmt.Errorf("error writing byte: %w", err) 89 | } 90 | } 91 | } 92 | 93 | // Rewind the 0x10xxxxxx that are UTF-8 continuation markers 94 | for i := position; i > 0; i-- { 95 | if (line[i] >> 6) != 0b10 { 96 | break 97 | } 98 | position-- 99 | } 100 | 101 | // XXX this will break every non latin1 line. 102 | s := " " 103 | if position < len(line)-1 { 104 | s = string(line[position : position+1]) 105 | } 106 | 107 | if _, err := b.WriteString(au.White(s).BgRed().String()); err != nil { 108 | return "", fmt.Errorf("error writing string: %w", err) 109 | } 110 | 111 | for i := position + 1; i < len(line); i++ { 112 | if line[i] != cr && line[i] != lf { 113 | if err := b.WriteByte(line[i]); err != nil { 114 | return "", fmt.Errorf("error writing byte: %w", err) 115 | } 116 | 117 | if (line[i] >> 6) == 0b10 { 118 | i++ 119 | } 120 | } 121 | } 122 | 123 | return b.String(), nil 124 | } 125 | -------------------------------------------------------------------------------- /print_test.go: -------------------------------------------------------------------------------- 1 | package eclint_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "testing" 8 | 9 | "gitlab.com/greut/eclint" 10 | ) 11 | 12 | func TestPrintErrors(t *testing.T) { 13 | tests := []struct { 14 | Name string 15 | HasOutput bool 16 | Errors []error 17 | }{ 18 | { 19 | Name: "no errors", 20 | HasOutput: false, 21 | Errors: []error{}, 22 | }, { 23 | Name: "simple error", 24 | HasOutput: true, 25 | Errors: []error{ 26 | errors.New("random error"), 27 | }, 28 | }, { 29 | Name: "validation error", 30 | HasOutput: true, 31 | Errors: []error{ 32 | eclint.ValidationError{}, 33 | }, 34 | }, { 35 | Name: "complete validation error", 36 | HasOutput: true, 37 | Errors: []error{ 38 | eclint.ValidationError{ 39 | Line: []byte("Hello"), 40 | Index: 1, 41 | Position: 2, 42 | }, 43 | }, 44 | }, 45 | } 46 | 47 | ctx := context.TODO() 48 | 49 | for _, tc := range tests { 50 | tc := tc 51 | 52 | // Test the nominal case 53 | t.Run(tc.Name, func(t *testing.T) { 54 | t.Parallel() 55 | buf := bytes.NewBuffer(make([]byte, 0, 1024)) 56 | opt := &eclint.Option{ 57 | Stdout: buf, 58 | } 59 | err := eclint.PrintErrors(ctx, opt, tc.Name, tc.Errors) 60 | if err != nil { 61 | t.Error("no errors were expected") 62 | } 63 | outputLength := buf.Len() 64 | if (outputLength > 0) != tc.HasOutput { 65 | t.Errorf("unexpected output length got %d, wanted %v", outputLength, tc.HasOutput) 66 | } 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /probes.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "os" 11 | "strings" 12 | 13 | "github.com/editorconfig/editorconfig-core-go/v2" 14 | "github.com/go-logr/logr" 15 | "github.com/gogs/chardet" 16 | ) 17 | 18 | // ProbeCharsetOrBinary does all the probes to detect the encoding 19 | // or whether it is a binary file. 20 | func ProbeCharsetOrBinary(ctx context.Context, r *bufio.Reader, charset string) (string, bool, error) { 21 | if charset == editorconfig.UnsetValue { 22 | return charset, false, nil 23 | } 24 | 25 | bs, err := r.Peek(512) 26 | if err != nil && !errors.Is(err, io.EOF) { 27 | return "", false, fmt.Errorf("cannot peek into reader: %w", err) 28 | } 29 | 30 | isBinary := probeMagic(ctx, bs) 31 | 32 | if !isBinary { 33 | isBinary = probeBinary(ctx, bs) 34 | } 35 | 36 | if isBinary { 37 | return "", true, nil 38 | } 39 | 40 | cs, err := probeCharset(ctx, bs, charset) 41 | if err != nil { 42 | return "", false, fmt.Errorf("cannot probe charset: %w", err) 43 | } 44 | 45 | return cs, false, nil 46 | } 47 | 48 | // probeMagic searches for some text-based binary files such as PDF. 49 | func probeMagic(ctx context.Context, bs []byte) bool { 50 | log := logr.FromContextOrDiscard(ctx) 51 | 52 | if bytes.HasPrefix(bs, []byte("%PDF-")) { 53 | log.V(2).Info("magic for PDF was found", "prefix", bs[0:7]) 54 | 55 | return true 56 | } 57 | 58 | return false 59 | } 60 | 61 | // probeBinary tells if the reader is likely to be binary 62 | // 63 | // checking for \0 is a weak strategy. 64 | func probeBinary(_ context.Context, bs []byte) bool { 65 | cont := 0 66 | 67 | l := len(bs) 68 | for i := 0; i < l; i++ { 69 | b := bs[i] 70 | 71 | switch { 72 | case b&0b1000_0000 == 0x00: 73 | continue 74 | 75 | case b&0b1100_0000 == 0b1000_0000: 76 | // found continuation, probably binary 77 | return true 78 | 79 | case b&0b1110_0000 == 0b1100_0000: 80 | // found leading of two bytes 81 | cont = 1 82 | case b&0b1111_0000 == 0b1110_0000: 83 | // found leading of three bytes 84 | cont = 2 85 | case b&0b1111_1000 == 0b1111_0000: 86 | // found leading of four bytes 87 | cont = 3 88 | case b == 0x00: 89 | // found NUL byte, probably binary 90 | return true 91 | } 92 | 93 | for ; cont > 0 && i < l-1; cont-- { 94 | i++ 95 | b = bs[i] 96 | 97 | if b&0b1100_0000 != 0b1000_0000 { 98 | // found something different than a continuation, 99 | // probably binary 100 | return true 101 | } 102 | } 103 | } 104 | 105 | return cont > 0 106 | } 107 | 108 | func probeCharset(ctx context.Context, bs []byte, charset string) (string, error) { 109 | log := logr.FromContextOrDiscard(ctx) 110 | 111 | // empty files are valid text files 112 | if len(bs) == 0 { 113 | return charset, nil 114 | } 115 | 116 | var cs string 117 | // The first line may contain the BOM for detecting some encodings 118 | cs = detectCharsetUsingBOM(bs) 119 | 120 | if charset != "" && cs != "" && cs != charset { 121 | return "", ValidationError{ 122 | Message: fmt.Sprintf("no %s prefix were found, got %q", charset, cs), 123 | } 124 | } 125 | 126 | if cs == "" && charset != "" { 127 | c, err := detectCharset(charset, bs) 128 | if err != nil { 129 | return "", err 130 | } 131 | 132 | cs = c 133 | 134 | // latin1 is a strict subset of utf-8 135 | if charset != cs { 136 | return "", ValidationError{ 137 | Message: fmt.Sprintf("detected charset %q does not match expected %q", cs, charset), 138 | } 139 | } 140 | 141 | log.V(3).Info("detect using chardet", "charset", charset) 142 | } 143 | 144 | return cs, nil 145 | } 146 | 147 | // probeReadable tries to read the file. When empty or a directory 148 | // it's considered non-readable with no errors. Otherwise the error 149 | // should be caught. 150 | func probeReadable(fp *os.File, r *bufio.Reader) (bool, error) { 151 | // Sanity check that the file can be read. 152 | _, err := r.Peek(1) 153 | if err != nil && !errors.Is(err, io.EOF) { 154 | if !errors.Is(err, io.EOF) { 155 | return false, nil 156 | } 157 | 158 | fi, err := fp.Stat() 159 | if err != nil { 160 | return false, fmt.Errorf("cannot stat file: %w", err) 161 | } 162 | 163 | if fi.IsDir() { 164 | return false, nil 165 | } 166 | 167 | return false, nil 168 | } 169 | 170 | return true, nil 171 | } 172 | 173 | // detectCharsetUsingBOM checks the charset via the first bytes of the first line. 174 | func detectCharsetUsingBOM(data []byte) string { 175 | switch { 176 | case bytes.HasPrefix(data, utf32leBom): 177 | return "utf-32le" 178 | case bytes.HasPrefix(data, utf32beBom): 179 | return "utf-32be" 180 | case bytes.HasPrefix(data, utf16leBom): 181 | return "utf-16le" 182 | case bytes.HasPrefix(data, utf16beBom): 183 | return "utf-16be" 184 | case bytes.HasPrefix(data, utf8Bom): 185 | return "utf-8 bom" 186 | } 187 | 188 | return "" 189 | } 190 | 191 | // detectCharset detects the file encoding. 192 | func detectCharset(charset string, data []byte) (string, error) { 193 | if charset == "" { 194 | return charset, nil 195 | } 196 | 197 | d := chardet.NewTextDetector() 198 | 199 | results, err := d.DetectAll(data) 200 | if err != nil { 201 | return "", fmt.Errorf("charset detection failure: %w", err) 202 | } 203 | 204 | hasLatin1 := false 205 | hasUtf8 := false 206 | 207 | for _, result := range results { 208 | if strings.HasPrefix(result.Charset, "ISO-8859-") { 209 | hasLatin1 = true 210 | } else if result.Charset == "UTF-8" { 211 | hasUtf8 = true 212 | } 213 | } 214 | 215 | if hasUtf8 && charset == Utf8 { 216 | return charset, nil 217 | } 218 | 219 | if hasLatin1 && charset == Latin1 { 220 | return charset, nil 221 | } 222 | 223 | return "", fmt.Errorf("%w: got the following charset(s) %q which are not supported", ErrConfiguration, charset) 224 | } 225 | -------------------------------------------------------------------------------- /probes_test.go: -------------------------------------------------------------------------------- 1 | package eclint_test 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "encoding/binary" 8 | "testing" 9 | "unicode/utf16" 10 | 11 | "gitlab.com/greut/eclint" 12 | ) 13 | 14 | func utf16le(s string) []byte { 15 | buf := new(bytes.Buffer) 16 | binary.Write(buf, binary.LittleEndian, []uint16{0xfeff}) //nolint:errcheck 17 | binary.Write(buf, binary.LittleEndian, utf16.Encode([]rune(s))) //nolint:errcheck 18 | 19 | return buf.Bytes() 20 | } 21 | 22 | func utf16be(s string) []byte { 23 | buf := new(bytes.Buffer) 24 | binary.Write(buf, binary.BigEndian, []uint16{0xfeff}) //nolint:errcheck 25 | binary.Write(buf, binary.BigEndian, utf16.Encode([]rune(s))) //nolint:errcheck 26 | 27 | return buf.Bytes() 28 | } 29 | 30 | func TestProbeCharsetOfBinary(t *testing.T) { 31 | tests := []struct { 32 | Name string 33 | Charset string 34 | File []byte 35 | }{ 36 | { 37 | Name: "utf-8", 38 | Charset: "utf-8", 39 | File: []byte{'h', 'i', ' ', 0xf0, 0x9f, 0x92, 0xa9, '!'}, 40 | }, { 41 | Name: "empty utf-8", 42 | Charset: "utf-8", 43 | File: []byte(""), 44 | }, { 45 | Name: "utf-8 bom", 46 | Charset: "utf-8 bom", 47 | File: []byte{0xef, 0xbb, 0xbf, 'h', 'e', 'l', 'l', 'o', '.'}, 48 | }, { 49 | Name: "latin1", 50 | Charset: "latin1", 51 | File: []byte("Hello world."), 52 | }, { 53 | Name: "utf-16le", 54 | Charset: "utf-16le", 55 | File: utf16le("Hello world."), 56 | }, { 57 | Name: "utf-16be", 58 | Charset: "utf-16be", 59 | File: utf16be("Hello world."), 60 | }, { 61 | Name: "utf-32le", 62 | Charset: "utf-32le", 63 | File: []byte{0xff, 0xfe, 0, 0, 'h', 0, 0, 0}, 64 | }, { 65 | Name: "utf-32be", 66 | Charset: "utf-32be", 67 | File: []byte{0, 0, 0xfe, 0xff, 0, 0, 0, 'h'}, 68 | }, 69 | } 70 | 71 | ctx := context.TODO() 72 | 73 | for _, tc := range tests { 74 | tc := tc 75 | t.Run(tc.Name, func(t *testing.T) { 76 | t.Parallel() 77 | 78 | r := bytes.NewReader(tc.File) 79 | br := bufio.NewReader(r) 80 | 81 | charset, ok, err := eclint.ProbeCharsetOrBinary(ctx, br, tc.Charset) 82 | if err != nil { 83 | t.Errorf("no errors were expected, got %s", err) 84 | } 85 | 86 | if ok { 87 | t.Errorf("no binary should have been detected") 88 | } 89 | 90 | if charset != tc.Charset { 91 | t.Errorf("bad charset. expected %s, got %s", tc.Charset, charset) 92 | } 93 | }) 94 | } 95 | } 96 | 97 | func TestProbeCharsetOfBinaryFailure(t *testing.T) { 98 | tests := []struct { 99 | Name string 100 | Charset string 101 | File []byte 102 | }{ 103 | { 104 | Name: "utf-8 vs latin1", 105 | Charset: "latin1", 106 | File: []byte{'h', 'i', ' ', 0xf0, 0x9f, 0x92, 0xa9, '!'}, 107 | }, { 108 | Name: "utf-8 vs utf-8 bom", 109 | Charset: "utf-8 bom", 110 | File: []byte{'h', 'i', ' ', 0xf0, 0x9f, 0x92, 0xa9, '!'}, 111 | }, { 112 | Name: "utf-8 bom vs utf-8", 113 | Charset: "utf-8", 114 | File: []byte{0xef, 0xbb, 0xbf, 'h', 'e', 'l', 'l', 'o', '.'}, 115 | }, 116 | } 117 | 118 | ctx := context.TODO() 119 | 120 | for _, tc := range tests { 121 | tc := tc 122 | t.Run(tc.Name, func(t *testing.T) { 123 | t.Parallel() 124 | 125 | r := bytes.NewReader(tc.File) 126 | br := bufio.NewReader(r) 127 | 128 | charset, ok, err := eclint.ProbeCharsetOrBinary(ctx, br, tc.Charset) 129 | if err == nil { 130 | t.Errorf("an error was expected, got charset %s, %v", charset, ok) 131 | } 132 | }) 133 | } 134 | } 135 | 136 | func TestProbeCharsetOfBinaryForBinary(t *testing.T) { 137 | tests := []struct { 138 | Name string 139 | Charset string 140 | File []byte 141 | }{ 142 | { 143 | Name: "euro but reversed", 144 | File: []byte{0xac, 0x82, 0xe2}, 145 | }, { 146 | Name: "euro but truncated", 147 | File: []byte{0xe2, 0x82}, 148 | }, { 149 | Name: "poop but middle only", 150 | File: []byte{0x9f, 0x92, 0xa9}, 151 | }, { 152 | Name: "poop emoji but truncated", 153 | File: []byte{0xf0, 0x9f, 0x92}, 154 | }, 155 | } 156 | 157 | ctx := context.TODO() 158 | 159 | for _, tc := range tests { 160 | tc := tc 161 | t.Run(tc.Name, func(t *testing.T) { 162 | t.Parallel() 163 | 164 | r := bytes.NewReader(tc.File) 165 | br := bufio.NewReader(r) 166 | 167 | charset, ok, err := eclint.ProbeCharsetOrBinary(ctx, br, "") 168 | if err != nil { 169 | t.Errorf("no errors were expected %s", err) 170 | } 171 | 172 | if !ok { 173 | t.Errorf("binary should have been detected got %s", charset) 174 | } 175 | }) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /scanner.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | ) 7 | 8 | // LineFunc is the callback for a line. 9 | // 10 | // It returns the line number starting from zero. 11 | type LineFunc func(int, []byte, bool) error 12 | 13 | // SplitLines works like bufio.ScanLines while keeping the line endings. 14 | func SplitLines(data []byte, atEOF bool) (int, []byte, error) { 15 | i := 0 16 | for i < len(data) { 17 | if data[i] == cr { 18 | i++ 19 | 20 | if i < len(data) && !atEOF { 21 | // Request more data 22 | return 0, nil, nil 23 | } 24 | 25 | if i < len(data) && data[i] == lf { 26 | i++ 27 | } 28 | 29 | return i, data[0:i], nil 30 | } else if data[i] == lf { 31 | i++ 32 | 33 | return i, data[0:i], nil 34 | } 35 | i++ 36 | } 37 | 38 | if !atEOF { 39 | // Request more data 40 | return 0, nil, nil 41 | } 42 | 43 | if atEOF && i != 0 { 44 | return 0, data, bufio.ErrFinalToken 45 | } 46 | 47 | return 0, nil, io.EOF 48 | } 49 | 50 | // ReadLines consumes the reader and emit each line via the LineFunc 51 | // 52 | // Line numbering starts at 0. Scanner is pretty smart an will reuse 53 | // its memory structure. This is something we explicitly avoid by copying 54 | // the content to a new slice. 55 | func ReadLines(r io.Reader, fileSize int64, fn LineFunc) []error { 56 | errs := make([]error, 0) 57 | sc := bufio.NewScanner(r) 58 | sc.Split(SplitLines) 59 | 60 | var read int64 61 | 62 | i := 0 63 | 64 | for sc.Scan() { 65 | l := sc.Bytes() 66 | line := make([]byte, len(l)) 67 | 68 | copy(line, l) 69 | 70 | read += int64(len(line)) 71 | 72 | if err := fn(i, line, read == fileSize); err != nil { 73 | errs = append(errs, err) 74 | } 75 | i++ 76 | } 77 | 78 | return errs 79 | } 80 | -------------------------------------------------------------------------------- /scanner_test.go: -------------------------------------------------------------------------------- 1 | package eclint_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | 8 | "gitlab.com/greut/eclint" 9 | ) 10 | 11 | func TestReadLines(t *testing.T) { 12 | tests := []struct { 13 | Name string 14 | File []byte 15 | LineFunc eclint.LineFunc 16 | }{ 17 | { 18 | Name: "Empty file", 19 | File: []byte(""), 20 | LineFunc: func(i int, line []byte, isEOF bool) error { 21 | if i != 0 || len(line) > 0 { 22 | return fmt.Errorf("more than one line found (%d), or non empty line %q", i, line) 23 | } 24 | 25 | return nil 26 | }, 27 | }, { 28 | Name: "crlf", 29 | File: []byte("\r\n\r\n"), 30 | LineFunc: func(i int, line []byte, isEOF bool) error { 31 | if i > 1 || len(line) > 2 { 32 | return fmt.Errorf("more than two lines found (%d), or non empty line %q", i, line) 33 | } 34 | 35 | return nil 36 | }, 37 | }, { 38 | Name: "cr", 39 | File: []byte("\r\r"), 40 | LineFunc: func(i int, line []byte, isEOF bool) error { 41 | if i > 1 || len(line) > 2 { 42 | return fmt.Errorf("more than two lines found (%d), or non empty line %q", i, line) 43 | } 44 | 45 | return nil 46 | }, 47 | }, { 48 | Name: "lf", 49 | File: []byte("\n\n"), 50 | LineFunc: func(i int, line []byte, isEOF bool) error { 51 | if i > 1 || len(line) > 2 { 52 | return fmt.Errorf("more than two lines found (%d), or non empty line %q", i, line) 53 | } 54 | 55 | return nil 56 | }, 57 | }, 58 | } 59 | 60 | for _, tc := range tests { 61 | tc := tc 62 | 63 | t.Run(tc.Name, func(t *testing.T) { 64 | t.Parallel() 65 | 66 | r := bytes.NewReader(tc.File) 67 | errs := eclint.ReadLines(r, -1, tc.LineFunc) 68 | if len(errs) > 0 { 69 | t.Errorf("no errors were expected, got some. %s", errs[0]) 70 | 71 | return 72 | } 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=greut_eclint 2 | sonar.organization=greut 3 | 4 | sonar.projectName=eclint 5 | 6 | sonar.sources=. 7 | sonar.sourceEncoding=UTF-8 8 | sonar.exclusions=**/*_test.go,**/vendor/** 9 | 10 | sonar.tests=. 11 | sonar.test.inclusions=**/*_test.go 12 | sonar.test.exclusions=**/vendor/** 13 | 14 | sonar.go.tests.reportPaths=test-report.json 15 | sonar.go.coverage.reportPaths=coverage.out 16 | sonar.go.golangci-lint.reportPaths=report.xml 17 | -------------------------------------------------------------------------------- /testdata/block_comments/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | # okay cases 8 | [a] 9 | block_comment_start = /* 10 | block_comment = * 11 | block_comment_end = */ 12 | 13 | [b] 14 | block_comment_start = /* 15 | block_comment_end = */ 16 | 17 | # this should break 18 | [c] 19 | block_comment_start = /* 20 | block_comment = * 21 | -------------------------------------------------------------------------------- /testdata/block_comments/a: -------------------------------------------------------------------------------- 1 | a 2 | -------------------------------------------------------------------------------- /testdata/block_comments/b: -------------------------------------------------------------------------------- 1 | b 2 | -------------------------------------------------------------------------------- /testdata/block_comments/c: -------------------------------------------------------------------------------- 1 | c 2 | -------------------------------------------------------------------------------- /testdata/charset/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [utf8.*] 4 | charset=utf-8 5 | 6 | [utf8-bom.*] 7 | charset=utf-8 bom 8 | 9 | [iso-8859-1.*] 10 | charset=latin1 11 | 12 | ; ASCII might be seen as UTF-8 when strict subset 13 | ; or latin when strict subset as well. Life is hard 14 | [ascii.*] 15 | charset=utf-8 16 | 17 | [ascii2.*] 18 | charset=latin1 19 | -------------------------------------------------------------------------------- /testdata/charset/ascii.txt: -------------------------------------------------------------------------------- 1 | ascii. 2 | -------------------------------------------------------------------------------- /testdata/charset/ascii2.txt: -------------------------------------------------------------------------------- 1 | ascii 2 | -------------------------------------------------------------------------------- /testdata/charset/iso-8859-1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greut/eclint/49a3bc348c10be271a82c7e0020de5f8eca3d5dd/testdata/charset/iso-8859-1.txt -------------------------------------------------------------------------------- /testdata/charset/utf8-bom.txt: -------------------------------------------------------------------------------- 1 | 👇☕ 2 | 💩 3 | -------------------------------------------------------------------------------- /testdata/charset/utf8.txt: -------------------------------------------------------------------------------- 1 | 👇☕ 2 | 💩 3 | -------------------------------------------------------------------------------- /testdata/images/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | max_line_length = 80 5 | -------------------------------------------------------------------------------- /testdata/images/edcon_tool.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greut/eclint/49a3bc348c10be271a82c7e0020de5f8eca3d5dd/testdata/images/edcon_tool.pdf -------------------------------------------------------------------------------- /testdata/images/edcon_tool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greut/eclint/49a3bc348c10be271a82c7e0020de5f8eca3d5dd/testdata/images/edcon_tool.png -------------------------------------------------------------------------------- /testdata/images/hello.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greut/eclint/49a3bc348c10be271a82c7e0020de5f8eca3d5dd/testdata/images/hello.txt.gz -------------------------------------------------------------------------------- /testdata/insert_final_newline/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.txt] 4 | insert_final_newline = true 5 | 6 | [*.md] 7 | insert_final_newline = false 8 | -------------------------------------------------------------------------------- /testdata/insert_final_newline/no_final_newline.md: -------------------------------------------------------------------------------- 1 | No final newline. -------------------------------------------------------------------------------- /testdata/insert_final_newline/no_final_newline.txt: -------------------------------------------------------------------------------- 1 | No final newline. -------------------------------------------------------------------------------- /testdata/insert_final_newline/with_final_newline.md: -------------------------------------------------------------------------------- 1 | Has a final new line. 2 | -------------------------------------------------------------------------------- /testdata/insert_final_newline/with_final_newline.txt: -------------------------------------------------------------------------------- 1 | Has a final new line. 2 | -------------------------------------------------------------------------------- /testdata/invalid/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [{.editorconfig,.}] 4 | charset="utf-8 bom" 5 | -------------------------------------------------------------------------------- /testdata/max_line_length/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [a] 4 | max_line_length = 99 5 | 6 | ; ok 7 | [b] 8 | max_line_length = 0 9 | 10 | [c] 11 | max_line_length = a 12 | -------------------------------------------------------------------------------- /testdata/max_line_length/a: -------------------------------------------------------------------------------- 1 | a 2 | -------------------------------------------------------------------------------- /testdata/max_line_length/b: -------------------------------------------------------------------------------- 1 | b 2 | -------------------------------------------------------------------------------- /testdata/max_line_length/c: -------------------------------------------------------------------------------- 1 | c 2 | -------------------------------------------------------------------------------- /testdata/simple/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = crlf 6 | indent_style = tab 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /testdata/simple/empty/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greut/eclint/49a3bc348c10be271a82c7e0020de5f8eca3d5dd/testdata/simple/empty/.keep -------------------------------------------------------------------------------- /testdata/simple/simple.txt: -------------------------------------------------------------------------------- 1 | Hello world. 2 | -------------------------------------------------------------------------------- /validators.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/editorconfig/editorconfig-core-go/v2" 9 | ) 10 | 11 | const ( 12 | cr = '\r' 13 | lf = '\n' 14 | tab = '\t' 15 | space = ' ' 16 | ) 17 | 18 | var ( 19 | utf8Bom = []byte{0xef, 0xbb, 0xbf} //nolint:gochecknoglobals 20 | utf16leBom = []byte{0xff, 0xfe} //nolint:gochecknoglobals 21 | utf16beBom = []byte{0xfe, 0xff} //nolint:gochecknoglobals 22 | utf32leBom = []byte{0xff, 0xfe, 0, 0} //nolint:gochecknoglobals 23 | utf32beBom = []byte{0, 0, 0xfe, 0xff} //nolint:gochecknoglobals 24 | ) 25 | 26 | // ErrConfiguration represents an error in the editorconfig value. 27 | var ErrConfiguration = errors.New("configuration error") 28 | 29 | // ValidationError is a rich type containing information about the error. 30 | type ValidationError struct { 31 | Message string 32 | Filename string 33 | Line []byte 34 | Index int 35 | Position int 36 | } 37 | 38 | func (e ValidationError) String() string { 39 | return e.Error() 40 | } 41 | 42 | // Error builds the error string. 43 | func (e ValidationError) Error() string { 44 | return fmt.Sprintf("%s:%d:%d: %s", e.Filename, e.Index+1, e.Position+1, e.Message) 45 | } 46 | 47 | // endOfLines checks the line ending. 48 | func endOfLine(eol string, data []byte) error { 49 | switch eol { 50 | case editorconfig.EndOfLineLf: 51 | if !bytes.HasSuffix(data, []byte{lf}) || bytes.HasSuffix(data, []byte{cr, lf}) { 52 | return ValidationError{ 53 | Message: "line does not end with lf (`\\n`)", 54 | Position: len(data), 55 | } 56 | } 57 | case editorconfig.EndOfLineCrLf: 58 | if !bytes.HasSuffix(data, []byte{cr, lf}) && !bytes.HasSuffix(data, []byte{0x00, cr, 0x00, lf}) { 59 | return ValidationError{ 60 | Message: "line does not end with crlf (`\\r\\n`)", 61 | Position: len(data), 62 | } 63 | } 64 | case editorconfig.EndOfLineCr: 65 | if !bytes.HasSuffix(data, []byte{cr}) { 66 | return ValidationError{ 67 | Message: "line does not end with cr (`\\r`)", 68 | Position: len(data), 69 | } 70 | } 71 | default: 72 | return fmt.Errorf("%w: %q is an invalid value for eol, want cr, crlf, or lf", ErrConfiguration, eol) 73 | } 74 | 75 | return nil 76 | } 77 | 78 | // indentStyle checks that the line beginnings are either space or tabs. 79 | func indentStyle(style string, size int, data []byte) error { 80 | var c byte 81 | 82 | var x byte 83 | 84 | switch style { 85 | case SpaceValue: 86 | c = space 87 | x = tab 88 | case TabValue: 89 | c = tab 90 | x = space 91 | size = 1 92 | case UnsetValue: 93 | return nil 94 | default: 95 | return fmt.Errorf("%w: %q is an invalid value of indent_style, want tab or space", ErrConfiguration, style) 96 | } 97 | 98 | if size == 0 { 99 | return nil 100 | } 101 | 102 | if size < 0 { 103 | return fmt.Errorf("%w: %d is an invalid value of indent_size, want a number or unset", ErrConfiguration, size) 104 | } 105 | 106 | for i := 0; i < len(data); i++ { 107 | if data[i] == c { 108 | continue 109 | } 110 | 111 | if data[i] == x { 112 | return ValidationError{ 113 | Message: fmt.Sprintf("indentation style mismatch expected %q (%s) got %q", c, style, x), 114 | Position: i, 115 | } 116 | } 117 | 118 | if data[i] == cr || data[i] == lf || (size > 0 && i%size == 0) { 119 | break 120 | } 121 | 122 | return ValidationError{ 123 | Message: fmt.Sprintf("indentation size doesn't match expected %d, got %d", size, i), 124 | Position: i, 125 | } 126 | } 127 | 128 | return nil 129 | } 130 | 131 | // checkInsertFinalNewline checks whenever the final line contains a newline or not. 132 | func checkInsertFinalNewline(data []byte, insertFinalNewline bool) error { 133 | if len(data) == 0 { 134 | return nil 135 | } 136 | 137 | lastChar := data[len(data)-1] 138 | if lastChar != cr && lastChar != lf { 139 | if insertFinalNewline { 140 | return ValidationError{ 141 | Message: "the final newline is missing", 142 | Position: len(data), 143 | } 144 | } 145 | } else { 146 | if !insertFinalNewline { 147 | return ValidationError{ 148 | Message: "an extraneous final newline was found", 149 | Position: len(data), 150 | } 151 | } 152 | } 153 | 154 | return nil 155 | } 156 | 157 | // checkTrimTrailingWhitespace lints any spaces before the final newline. 158 | func checkTrimTrailingWhitespace(data []byte) error { 159 | for i := len(data) - 1; i >= 0; i-- { 160 | if data[i] == cr || data[i] == lf { 161 | continue 162 | } 163 | 164 | if data[i] == space || data[i] == tab { 165 | return ValidationError{ 166 | Message: "line has some trailing whitespaces", 167 | Position: i, 168 | } 169 | } 170 | 171 | break 172 | } 173 | 174 | return nil 175 | } 176 | 177 | // isBlockCommentStart tells you when a block comment started on this line. 178 | func isBlockCommentStart(start []byte, data []byte) bool { 179 | for i := 0; i < len(data); i++ { 180 | if data[i] == space || data[i] == tab { 181 | continue 182 | } 183 | 184 | return bytes.HasPrefix(data[i:], start) 185 | } 186 | 187 | return false 188 | } 189 | 190 | // checkBlockComment checks the line is a valid block comment. 191 | func checkBlockComment(i int, prefix []byte, data []byte) error { 192 | for ; i < len(data); i++ { 193 | if data[i] == space || data[i] == tab { 194 | continue 195 | } 196 | 197 | if !bytes.HasPrefix(data[i:], prefix) { 198 | return ValidationError{ 199 | Message: fmt.Sprintf("block_comment prefix %q was expected inside a block comment", string(prefix)), 200 | Position: i, 201 | } 202 | } 203 | 204 | break 205 | } 206 | 207 | return nil 208 | } 209 | 210 | // isBlockCommentEnd tells you when a block comment end on this line. 211 | func isBlockCommentEnd(end []byte, data []byte) bool { 212 | for i := len(data) - 1; i > 0; i-- { 213 | if data[i] == cr || data[i] == lf { 214 | continue 215 | } 216 | 217 | return bytes.HasSuffix(data[:i+1], end) 218 | } 219 | 220 | return false 221 | } 222 | 223 | // MaxLineLength checks the length of a given line. 224 | // 225 | // It assumes UTF-8 and will count as one runes. The first byte has no prefix 226 | // 0xxxxxxx, 110xxxxx, 1110xxxx, 11110xxx, 111110xx, etc. and the following byte 227 | // the 10xxxxxx prefix which are skipped. 228 | func MaxLineLength(maxLength int, tabWidth int, data []byte) error { 229 | length := 0 230 | breakingPosition := 0 231 | 232 | for i := 0; i < len(data); i++ { 233 | if data[i] == cr || data[i] == lf { 234 | break 235 | } 236 | 237 | switch { 238 | case data[i] == tab: 239 | length += tabWidth 240 | case (data[i] >> 6) == 0b10: 241 | // skip 0x10xxxxxx that are UTF-8 continuation markers 242 | default: 243 | length++ 244 | } 245 | 246 | if length > maxLength && breakingPosition == 0 { 247 | breakingPosition = i 248 | } 249 | } 250 | 251 | if length > maxLength { 252 | return ValidationError{ 253 | Message: fmt.Sprintf("line is too long (%d > %d)", length, maxLength), 254 | Position: breakingPosition, 255 | } 256 | } 257 | 258 | return nil 259 | } 260 | -------------------------------------------------------------------------------- /validators_test.go: -------------------------------------------------------------------------------- 1 | package eclint 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestEndOfLine(t *testing.T) { 9 | tests := []struct { 10 | Name string 11 | EndOfLine string 12 | Line []byte 13 | }{ 14 | { 15 | Name: "crlf", 16 | EndOfLine: "crlf", 17 | Line: []byte("\r\n"), 18 | }, { 19 | Name: "lf", 20 | EndOfLine: "lf", 21 | Line: []byte("\n"), 22 | }, { 23 | Name: "cr", 24 | EndOfLine: "cr", 25 | Line: []byte("\r"), 26 | }, 27 | } 28 | for _, tc := range tests { 29 | tc := tc 30 | t.Run(tc.Name, func(t *testing.T) { 31 | t.Parallel() 32 | err := endOfLine(tc.EndOfLine, tc.Line) 33 | if err != nil { 34 | t.Errorf("no errors were expected, got %s", err) 35 | } 36 | }) 37 | } 38 | } 39 | 40 | func TestEndOfLineFailures(t *testing.T) { 41 | tests := []struct { 42 | Name string 43 | EndOfLine string 44 | Line []byte 45 | Position int 46 | }{ 47 | { 48 | Name: "cr instead of crlf", 49 | EndOfLine: "crlf", 50 | Line: []byte("\r"), 51 | Position: 1, 52 | }, { 53 | Name: "lf instead of crlf", 54 | EndOfLine: "crlf", 55 | Line: []byte("[*]\n"), 56 | Position: 4, 57 | }, { 58 | Name: "cr instead of lf", 59 | EndOfLine: "lf", 60 | Line: []byte("\r"), 61 | Position: 1, 62 | }, { 63 | Name: "crlf instead of lf", 64 | EndOfLine: "lf", 65 | Line: []byte("\r\n"), 66 | Position: 2, 67 | }, { 68 | Name: "crlf instead of cr", 69 | EndOfLine: "cr", 70 | Line: []byte("\r\n"), 71 | Position: 2, 72 | }, { 73 | Name: "lf instead of cr", 74 | EndOfLine: "cr", 75 | Line: []byte("hello\n"), 76 | Position: 6, 77 | }, { 78 | Name: "unknown eol", 79 | EndOfLine: "lfcr", 80 | Line: []byte("\n\r"), 81 | Position: -1, 82 | }, 83 | } 84 | for _, tc := range tests { 85 | tc := tc 86 | t.Run(tc.Name, func(t *testing.T) { 87 | t.Parallel() 88 | err := endOfLine(tc.EndOfLine, tc.Line) 89 | 90 | var ve ValidationError 91 | ok := errors.As(err, &ve) 92 | 93 | if tc.Position >= 0 { 94 | if !ok { 95 | t.Errorf("a ValidationError was expected, got %t", err) 96 | } 97 | if tc.Position != ve.Position { 98 | t.Errorf("position mismatch %d, got %d", tc.Position, ve.Position) 99 | } 100 | } else if err == nil { 101 | t.Error("an error was expected") 102 | } 103 | }) 104 | } 105 | } 106 | 107 | func TestTrimTrailingWhitespace(t *testing.T) { 108 | tests := []struct { 109 | Name string 110 | Line []byte 111 | }{ 112 | { 113 | Name: "crlf", 114 | Line: []byte("\r\n"), 115 | }, { 116 | Name: "cr", 117 | Line: []byte("\r"), 118 | }, { 119 | Name: "lf", 120 | Line: []byte("\n"), 121 | }, { 122 | Name: "words", 123 | Line: []byte("hello world."), 124 | }, { 125 | Name: "noeol", 126 | Line: []byte(""), 127 | }, { 128 | Name: "nbsp", 129 | Line: []byte{0xA0}, 130 | }, 131 | } 132 | for _, tc := range tests { 133 | tc := tc 134 | t.Run(tc.Name, func(t *testing.T) { 135 | t.Parallel() 136 | err := checkTrimTrailingWhitespace(tc.Line) 137 | if err != nil { 138 | t.Errorf("no errors were expected, got %s", err) 139 | } 140 | }) 141 | } 142 | } 143 | 144 | func TestTrimTrailingWhitespaceFailure(t *testing.T) { 145 | tests := []struct { 146 | Name string 147 | Line []byte 148 | }{ 149 | { 150 | Name: "space", 151 | Line: []byte(" \r\n"), 152 | }, { 153 | Name: "tab", 154 | Line: []byte("\t"), 155 | }, 156 | } 157 | 158 | for _, tc := range tests { 159 | tc := tc 160 | t.Run(tc.Name, func(t *testing.T) { 161 | t.Parallel() 162 | err := checkTrimTrailingWhitespace(tc.Line) 163 | if err == nil { 164 | t.Error("an error was expected") 165 | } 166 | }) 167 | } 168 | } 169 | 170 | func TestIndentStyle(t *testing.T) { 171 | tests := []struct { 172 | Name string 173 | IndentSize int 174 | IndentStyle string 175 | Line []byte 176 | }{ 177 | { 178 | Name: "empty line of tab", 179 | IndentSize: 1, 180 | IndentStyle: "tab", 181 | Line: []byte("\t\t\t\t."), 182 | }, { 183 | Name: "empty line of spaces", 184 | IndentSize: 1, 185 | IndentStyle: "space", 186 | Line: []byte(" ."), 187 | }, { 188 | Name: "three spaces", 189 | IndentSize: 3, 190 | IndentStyle: "space", 191 | Line: []byte(" ."), 192 | }, { 193 | Name: "three tabs", 194 | IndentSize: 3, 195 | IndentStyle: "tab", 196 | Line: []byte("\t\t\t\t."), 197 | }, { 198 | Name: "unset", 199 | IndentSize: 5, 200 | IndentStyle: "unset", 201 | Line: []byte("###"), 202 | }, 203 | } 204 | for _, tc := range tests { 205 | tc := tc 206 | t.Run(tc.Name, func(t *testing.T) { 207 | t.Parallel() 208 | err := indentStyle(tc.IndentStyle, tc.IndentSize, tc.Line) 209 | if err != nil { 210 | t.Errorf("no errors were expected, got %s", err) 211 | } 212 | }) 213 | } 214 | } 215 | 216 | func TestIndentStyleFailure(t *testing.T) { 217 | tests := []struct { 218 | Name string 219 | IndentSize int 220 | IndentStyle string 221 | Line []byte 222 | }{ 223 | { 224 | Name: "mix of tab and spaces", 225 | IndentSize: 2, 226 | IndentStyle: "space", 227 | Line: []byte(" \t."), 228 | }, { 229 | Name: "mix of tabs and space spaces", 230 | IndentSize: 2, 231 | IndentStyle: "tab", 232 | Line: []byte("\t \t."), 233 | }, { 234 | Name: "three spaces +1", 235 | IndentSize: 3, 236 | IndentStyle: "space", 237 | Line: []byte(" ."), 238 | }, { 239 | Name: "three spaces -1", 240 | IndentSize: 3, 241 | IndentStyle: "space", 242 | Line: []byte(" ."), 243 | }, { 244 | Name: "invalid size", 245 | IndentSize: -1, 246 | IndentStyle: "space", 247 | Line: []byte("."), 248 | }, { 249 | Name: "invalid style", 250 | IndentSize: 3, 251 | IndentStyle: "fubar", 252 | Line: []byte("."), 253 | }, 254 | } 255 | for _, tc := range tests { 256 | tc := tc 257 | t.Run(tc.Name, func(t *testing.T) { 258 | t.Parallel() 259 | err := indentStyle(tc.IndentStyle, tc.IndentSize, tc.Line) 260 | if err == nil { 261 | t.Error("an error was expected") 262 | } 263 | }) 264 | } 265 | } 266 | 267 | func TestCheckBlockComment(t *testing.T) { 268 | tests := []struct { 269 | Name string 270 | Position int 271 | Prefix []byte 272 | Line []byte 273 | }{ 274 | { 275 | Name: "Java", 276 | Position: 5, 277 | Prefix: []byte{'*'}, 278 | Line: []byte("\t\t\t\t *\r\n"), 279 | }, 280 | } 281 | for _, tc := range tests { 282 | tc := tc 283 | t.Run(tc.Name, func(t *testing.T) { 284 | t.Parallel() 285 | err := checkBlockComment(tc.Position, tc.Prefix, tc.Line) 286 | if err != nil { 287 | t.Errorf("no errors were expected, got %s", err) 288 | } 289 | }) 290 | } 291 | } 292 | 293 | func TestMaxLineLength(t *testing.T) { 294 | tests := []struct { 295 | Name string 296 | MaxLineLength int 297 | TabWidth int 298 | Line []byte 299 | }{ 300 | { 301 | Name: "no limits", 302 | MaxLineLength: 0, 303 | TabWidth: 0, 304 | Line: []byte("\r\n"), 305 | }, { 306 | Name: "some limit", 307 | MaxLineLength: 1, 308 | TabWidth: 0, 309 | Line: []byte(".\r\n"), 310 | }, { 311 | Name: "some limit", 312 | MaxLineLength: 10, 313 | TabWidth: 0, 314 | Line: []byte("0123456789\n"), 315 | }, { 316 | Name: "tabs", 317 | MaxLineLength: 5, 318 | TabWidth: 2, 319 | Line: []byte("\t\t.\n"), 320 | }, { 321 | Name: "utf-8 encoded characters", 322 | MaxLineLength: 1, 323 | TabWidth: 0, 324 | Line: []byte("é\n"), 325 | }, { 326 | Name: "utf-8 emojis", 327 | MaxLineLength: 7, 328 | TabWidth: 0, 329 | Line: []byte("🐵 🙈 🙉 🙊\r\n"), 330 | }, { 331 | Name: "VMWare Inc, Globalization Team super string", 332 | MaxLineLength: 17, 333 | TabWidth: 0, 334 | Line: []byte("表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀\r"), 335 | }, 336 | } 337 | for _, tc := range tests { 338 | tc := tc 339 | t.Run(tc.Name, func(t *testing.T) { 340 | t.Parallel() 341 | err := MaxLineLength(tc.MaxLineLength, tc.TabWidth, tc.Line) 342 | if err != nil { 343 | t.Errorf("no errors were expected, got %s", err) 344 | } 345 | }) 346 | } 347 | } 348 | 349 | func TestMaxLineLengthFailure(t *testing.T) { 350 | tests := []struct { 351 | Name string 352 | MaxLineLength int 353 | TabWidth int 354 | Line []byte 355 | }{ 356 | { 357 | Name: "small limit", 358 | MaxLineLength: 1, 359 | TabWidth: 1, 360 | Line: []byte("..\r\n"), 361 | }, { 362 | Name: "small limit and tab", 363 | MaxLineLength: 2, 364 | TabWidth: 2, 365 | Line: []byte("\t.\r\n"), 366 | }, 367 | } 368 | for _, tc := range tests { 369 | tc := tc 370 | t.Run(tc.Name, func(t *testing.T) { 371 | t.Parallel() 372 | err := MaxLineLength(tc.MaxLineLength, tc.TabWidth, tc.Line) 373 | if err == nil { 374 | t.Error("an error was expected") 375 | } 376 | }) 377 | } 378 | } 379 | --------------------------------------------------------------------------------