diff --git a/.drone.yml b/.drone.yml index 0b2321b..7bf699b 100644 --- a/.drone.yml +++ b/.drone.yml @@ -8,6 +8,7 @@ platform: os: linux steps: + - name: get image: golang:1.14.0 commands: @@ -18,6 +19,13 @@ steps: image: fernandrone/linelint:latest pull: true + - name: markdown + image: node:14.13.1 + group: test + commands: + - npm install -g markdownlint-cli + - markdownlint . + - name: golangci-lint group: test image: golangci/golangci-lint:v1.26.0 diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..e120670 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "MD013": false, + "MD033": false +} diff --git a/README.md b/README.md index 1520e7e..7377954 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Build Status](https://cloud.drone.io/api/badges/fernandrone/linelint/status.svg)](https://cloud.drone.io/fernandrone/linelint) [![Go Report Card](https://goreportcard.com/badge/github.com/fernandrone/linelint)](https://goreportcard.com/report/github.com/fernandrone/linelint) -A linter that validates simple _newline_ and _whitespace_ rules in all sorts of files. It can: +A linter that validates simple *newline* and *whitespace* rules in all sorts of files. It can: - Recursively check a directory tree for files that do not end in a newline - Automatically fix these files by adding a newline or trimming extra newlines @@ -31,7 +31,7 @@ See the **[#GitHub Actions](#GitHub-Actions)** and the **[#Docker](#Docker)** fo > This is a project in development. Use it at your own risk! -To run it locally, execute the binary and pass a list of file or directories as argument. +Executing the binary will automatically search the local directory tree for linting errors. ```console $ linelint . @@ -41,7 +41,7 @@ $ linelint . Total of 2 lint errors! ``` -Or: +Pass a list of files or directories to limit your search. ```console $ linelint README.md LICENSE linter/config.go @@ -52,7 +52,9 @@ Total of 1 lint errors! After checking all files, in case any rule has failed, Linelint will finish with an error (exit code 1). -If the `autofix` option is set to `true` (it is `false` by default, activate it with the `-a` flag), Linelint will attempt to fix any file with error by rewriting it. +### AutoFix + +If the `autofix` option is set to `true` (it is `false` by default, activate it with the `-a` flag or set it in the configuration file), Linelint will attempt to fix any linting error by rewriting the file. ```console $ linelint -a . @@ -62,21 +64,43 @@ $ linelint -a . [EOF Rule] File "linter/eof.go" lint errors fixed ``` -When all files are fixed successfully, Linelint terminates with with a success as well (exit code 0). +If all files are fixed successfully, Linelint terminates with exit code 0. + +### Stdin + +Pass "-" as an argument to read data from standard input instead of a list of files. + +```console +$ cat hello.txt +Hello World + + +``` + +```console +$ cat hello.txt | linelint - +Hello World +``` + +When reading from stdin, linelint behavior changes and it won't report lint errors. Instead when autofix is on, it will fix them and output the result to `/dev/stdout`. When autofix is off, it will terminate the program with an error code in case there are any linting violations, but won't output anything. + +### Help + +At any time run `linenlint --help` for a list of available command line arguments. ## Configuration -Create a `.linelint.yml` file in the same working directory you run `linelint` to adjust your settings. See [.linelint.yml](.linelint.yml) for an up-to-date example: +Create a `.linelint.yml` file in the same working directory you run `linelint` to adjust your settings. See [.linelint.yml](.linelint.yml) for an up-to-date example. ## Rules -Right now it only supports a single rule, "End of File", which is enabled by default. +Right now it supports only a single rule, "End of File", which is enabled by default. ### EndOfFile -The _End of File_ rule checks if the file ends in a newline character, or `\n`. You may find this rule useful if you dislike seeing these đŸš« symbols at the end of files on GitHub Pull Requests. +The *End of File* rule checks if the file ends in a newline character, or `\n`. You may find it useful if you dislike seeing these đŸš« symbols at the end of files on GitHub Pull Requests. -By default it also checks if it ends strictly in a single newline character. This behavior can be disabled by setting the `single-new-line` parameter to `false`. +By default it also checks if it strictly ends in a single newline character. This behavior can be disabled by setting the `single-new-line` parameter to `false`. ```yaml rules: @@ -102,7 +126,7 @@ This project is available at the [GitHub Actions Marketplace](https://github.com Create a workflow file at your repository's Workflow folder, like `.github/workflows/lint.yml` (see [lint.yml](.github/workflows/lint.yml) for an updated example): -``` +```yaml # .github/workflows/main.yml on: [push] name: lint diff --git a/go.sum b/go.sum index 772f011..d958512 100644 --- a/go.sum +++ b/go.sum @@ -221,6 +221,7 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kyoh86/exportloopref v0.1.7 h1:u+iHuTbkbTS2D/JP7fCuZDo/t3rBVGo3Hf58Rc+lQVY= github.com/kyoh86/exportloopref v0.1.7/go.mod h1:h1rDl2Kdj97+Kwh4gdz3ujE7XHmH51Q0lUiZ1z4NLj8= @@ -265,6 +266,7 @@ github.com/nakabonne/nestif v0.3.0 h1:+yOViDGhg8ygGrmII72nV9B/zGxY188TYpfolntsaP github.com/nakabonne/nestif v0.3.0/go.mod h1:dI314BppzXjJ4HsCnbo7XzrJHPszZsjnk5wEBSYHI2c= github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d h1:AREM5mwr4u1ORQBMvzfzBgpsctsbQikCVpvC+tX285E= github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nishanths/exhaustive v0.0.0-20200708172631-8866003e3856 h1:W3KBC2LFyfgd+wNudlfgCCsTo4q97MeNWrfz8/wSdSc= github.com/nishanths/exhaustive v0.0.0-20200708172631-8866003e3856/go.mod h1:wBEpHwM2OdmeNpdCvRPUlkEbBuaFmcK4Wv8Q7FuGW3c= @@ -562,6 +564,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= diff --git a/linelint.go b/linelint.go index 3267ee8..5609810 100644 --- a/linelint.go +++ b/linelint.go @@ -2,8 +2,10 @@ package main import ( "bufio" + "errors" "flag" "fmt" + "io" "io/ioutil" "os" "path/filepath" @@ -11,52 +13,160 @@ import ( "github.com/fernandrone/linelint/linter" ) -const helpMsg = `usage of %s [-a] [FILE_OR_DIR [FILE_OR_DIR ...]] +const ( + configFile = "./.linelint.yml" + helpMsg = `usage of %s [-a] [FILE_OR_DIR [FILE_OR_DIR ...]] Validates simple newline and whitespace rules in all sorts of files. positional arguments: - FILE_OR_DIR files to format + FILE_OR_DIR files to format or '-' for stdin optional arguments: ` +) + +// Input is the main input structure to the program +type Input struct { + Paths []string + Stdin io.Reader + Config linter.Config +} func main() { var flagAutofix bool - flag.BoolVar(&flagAutofix, "a", false, "(autofix) will automatically fix files with errors in place") + + flag.BoolVar( + &flagAutofix, "a", false, "(autofix) will automatically fix files with errors in place", + ) + flag.Usage = func() { fmt.Fprintf(os.Stderr, helpMsg, os.Args[0]) flag.PrintDefaults() } flag.Parse() - var args, paths []string + var paths []string if flag.NArg() == 0 { - args = []string{"."} + paths = []string{"."} } else { - args = flag.Args() + paths = flag.Args() + } + + config := linter.NewConfigFromFile(configFile) + + if flagAutofix { + config.AutoFix = true + } + + input := Input{ + Paths: paths, + Stdin: os.Stdin, + Config: config, + } + + if err := run(input); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } +} + +func run(in Input) error { + var linters []linter.Linter + + if in.Config.Rules.EndOfFile.Enable { + linters = append(linters, linter.NewEndOfFileRule(in.Config)) + } + + if len(linters) == 0 { + return errors.New("No valid rule enabled") + } + + // read from stdin + if len(in.Paths) == 1 && in.Paths[0] == "-" { + return processSTDIN(in, linters) + } + + return processDirectoryTree(in, linters) +} + +func processSTDIN(in Input, linters []linter.Linter) error { + var lintErrors int + + b, err := ioutil.ReadAll(in.Stdin) + + if err != nil { + return fmt.Errorf("Error reading from Stdin: %v", err) + } + + if !linter.IsText(b) { + return errors.New("Stdin is not a valid UFT-8 input") + } + + for _, rule := range linters { + + valid, fix := rule.Lint(b) + + if !valid { + lintErrors++ + } + + if fix != nil { + + if err != nil { + return fmt.Errorf("[%s] Failed to fix Stdin: %v\n", rule.GetName(), err) + } + + w := bufio.NewWriter(os.Stdout) + defer w.Flush() + + _, err = w.Write(fix) + + if err != nil { + return fmt.Errorf("[%s] Failed to print fixed input to Stdout: %v\n", + rule.GetName(), err, + ) + } + + err = w.Flush() + + if err != nil { + return fmt.Errorf("[%s] Failed to flush fixed input to Stdout: %v\n", + rule.GetName(), err, + ) + } + + lintErrors-- + } + } + + if lintErrors != 0 { + // call exit directly to disable the error message + os.Exit(1) } - config := linter.NewConfig() - config.AutoFix = flagAutofix + return nil +} + +func processDirectoryTree(in Input, linters []linter.Linter) error { + var files []string - // get paths to ignore - ignore := linter.MustCompileIgnoreLines(config.Ignore...) + // get patterns to ignore + ignore := linter.MustCompileIgnoreLines(in.Config.Ignore...) - for _, path := range args { + for _, path := range in.Paths { f, err := os.Stat(path) if os.IsNotExist(err) { - fmt.Printf("File %q does not exist", path) - os.Exit(1) + return fmt.Errorf("File %q does not exist", path) } // if dir, walk and append only files if f.IsDir() { err = filepath.Walk(path, func(p string, info os.FileInfo, err error) error { if err != nil { - fmt.Printf("Prevent panic by handling failure accessing a path %q: %v\n", p, err) + fmt.Printf("Prevent panic by handling failure accessing %q: %v\n", p, err) return err } @@ -69,38 +179,26 @@ func main() { return nil } - paths = append(paths, p) + files = append(files, p) return nil }) if err != nil { - fmt.Printf("Error walking the path %q: %v\n", path, err) - return + return fmt.Errorf("Error walking the path %q: %v\n", path, err) } } else { // if not dir, append - paths = append(paths, path) + files = append(files, path) } } var fileErrors, lintErrors int - var linters []linter.Linter - // TODO a better code for selecting rules - if config.Rules.EndOfFile.Enable { - linters = append(linters, linter.NewEndOfFileRule(config)) - } - - if len(linters) == 0 { - fmt.Printf("Fatal: no valid rule enabled\n") - os.Exit(1) - } - - for _, path := range paths { + for _, f := range files { - fr, err := os.Open(path) + fr, err := os.Open(f) if err != nil { - fmt.Printf("Error opening file %q: %v\n", path, err) + fmt.Printf("Error opening file %q: %v\n", f, err) fileErrors++ continue } @@ -108,34 +206,35 @@ func main() { defer fr.Close() if err != nil { - fmt.Printf("Skipping file %q: %v\n", path, err) + fmt.Printf("Skipping file %q: %v\n", f, err) continue } b, err := ioutil.ReadAll(fr) if err != nil { - fmt.Printf("Error reading file %q: %v\n", path, err) + fmt.Printf("Error reading file %q: %v\n", f, err) fileErrors++ continue } if !linter.IsText(b) { + // TODO add log levels // fmt.Printf("Ignoring file %q: not text file\n", path) continue } for _, rule := range linters { - if rule.ShouldIgnore(path) { - fmt.Printf("[%s] Ignoring file %q: in rule ignore path\n", rule.GetName(), path) + if rule.ShouldIgnore(f) { + fmt.Printf("[%s] Ignoring file %q: in rule ignore path\n", rule.GetName(), f) continue } valid, fix := rule.Lint(b) if !valid { - fmt.Printf("[%s] File %q has lint errors\n", rule.GetName(), path) + fmt.Printf("[%s] File %q has lint errors\n", rule.GetName(), f) lintErrors++ } @@ -143,12 +242,11 @@ func main() { fr.Close() if fix != nil { - // will erase the file - fw, err := os.Create(path) + fw, err := os.Create(f) if err != nil { - fmt.Printf("[%s] Failed to fix file %q: %v\n", rule.GetName(), path, err) + fmt.Printf("[%s] Failed to fix file %q: %v\n", rule.GetName(), f, err) break } @@ -160,18 +258,18 @@ func main() { _, err = w.Write(fix) if err != nil { - fmt.Printf("[%s] Failed to fix file %q: %v\n", rule.GetName(), path, err) + fmt.Printf("[%s] Failed to fix file %q: %v\n", rule.GetName(), f, err) break } err = w.Flush() if err != nil { - fmt.Printf("[%s] Failed to flush file %q: %v\n", rule.GetName(), path, err) + fmt.Printf("[%s] Failed to flush file %q: %v\n", rule.GetName(), f, err) break } - fmt.Printf("[%s] File %q lint errors fixed\n", rule.GetName(), path) + fmt.Printf("[%s] File %q lint errors fixed\n", rule.GetName(), f) lintErrors-- // ignore errors @@ -189,6 +287,9 @@ func main() { } if fileErrors != 0 || lintErrors != 0 { + // call exit directly to disable the error message os.Exit(1) } + + return nil } diff --git a/linelint_test.go b/linelint_test.go new file mode 100644 index 0000000..45024eb --- /dev/null +++ b/linelint_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "strings" + + "github.com/fernandrone/linelint/linter" +) + +const textWithSingleNewLine = ` +As armas e os BarĂ”es assinalados +Que da Ocidental praia Lusitana, +Por mares nunca de antes navegados +Passaram ainda alĂ©m da Taprobana, +Em perigos e guerras esforçados, +Mais do que prometia a força humana, +E entre gente remota edificaram +Novo reino, que tanto sublimaram; +` + +const textWithTwoNewLines = ` +As armas e os BarĂ”es assinalados +Que da Ocidental praia Lusitana, +Por mares nunca de antes navegados +Passaram ainda alĂ©m da Taprobana, +Em perigos e guerras esforçados, +Mais do que prometia a força humana, +E entre gente remota edificaram +Novo reino, que tanto sublimaram; + +` + +func Example_two_new_lines() { + c := linter.NewDefaultConfig() + c.AutoFix = true + + input := Input{ + Paths: []string{"-"}, + Stdin: strings.NewReader(textWithTwoNewLines), + Config: c, + } + + if err := run(input); err != nil { + panic(err) + } + + // Output: + // As armas e os BarĂ”es assinalados + // Que da Ocidental praia Lusitana, + // Por mares nunca de antes navegados + // Passaram ainda alĂ©m da Taprobana, + // Em perigos e guerras esforçados, + // Mais do que prometia a força humana, + // E entre gente remota edificaram + // Novo reino, que tanto sublimaram; +} + +func Example_single_new_line() { + c := linter.NewDefaultConfig() + c.AutoFix = true + + input := Input{ + Paths: []string{"-"}, + Stdin: strings.NewReader(textWithSingleNewLine), + Config: c, + } + + if err := run(input); err != nil { + panic(err) + } + + // Output: +} diff --git a/linter/config.go b/linter/config.go index 6c7745c..9e04f95 100644 --- a/linter/config.go +++ b/linter/config.go @@ -12,6 +12,7 @@ import ( type Config struct { // AutoFix sets if the linter should try to fix the error AutoFix bool `yaml:"autofix"` + Verbose bool `yaml:"verbose"` // Ignore uses the gitignore syntax the select which files or folders to ignore Ignore []string `yaml:"ignore"` @@ -37,34 +38,33 @@ type EndOfFileConfig struct { SingleNewLine bool `yaml:"single-new-line"` } -// NewConfig returns a new Config -func NewConfig() Config { - path := ".linelint.yml" - +// NewConfigFromFile returns a new Config +func NewConfigFromFile(path string) Config { var data []byte // check if config file exists if _, err := os.Stat(path); err != nil { - return newDefaultConfig() + fmt.Printf("No configuration file found at %s (will use default configuration)\n", path) + return NewDefaultConfig() } // if config file does exist, read it data, err := ioutil.ReadFile(path) if err != nil { - fmt.Printf("Error reading YAML file %s: %s (will use default configuration)\n", path, err) - return newDefaultConfig() + fmt.Printf("Error reading configuration file %s: %s (will use default configuration)\n", path, err) + return NewDefaultConfig() } var config Config if err := yaml.Unmarshal(data, &config); err != nil { - fmt.Printf("Error parsing YAML file: %s (will use default configuration)\n", err) - return newDefaultConfig() + fmt.Printf("Error parsing configuration file: %s (will use default configuration)\n", err) + return NewDefaultConfig() } return config } -func newDefaultConfig() Config { +func NewDefaultConfig() Config { return Config{ AutoFix: false, Ignore: []string{".git/"}, diff --git a/linter/config_test.go b/linter/config_test.go index 29288cb..3461fe2 100644 --- a/linter/config_test.go +++ b/linter/config_test.go @@ -39,7 +39,7 @@ func TestDefaultConfig(t *testing.T) { t.Fatalf("yaml.Unmarshal(Config): %v", err) } - if !reflect.DeepEqual(c, newDefaultConfig()) { + if !reflect.DeepEqual(c, NewDefaultConfig()) { t.Errorf("yaml.Unmarshal(Config):\n\tExpected %+v, got %+v", autofixTestConf, c) } } diff --git a/linter/ignore_test.go b/linter/ignore_test.go index 1ed1de6..10710c6 100644 --- a/linter/ignore_test.go +++ b/linter/ignore_test.go @@ -6,55 +6,73 @@ import ( "gopkg.in/yaml.v2" ) -var ignoreTests = []struct { - file string - ignore bool -}{ - {"README", false}, - {".git/objects/04/9f2973ffc85f71da1fd5a", true}, -} +func TestShouldIgnore_DefaultConf(t *testing.T) { + c := Config{} -var yamlAutofixTestConfig = ` -autofix: true + err := yaml.Unmarshal([]byte(`ignore: [ ".git/" ]`), &c) -ignore: - - .git/ + if err != nil { + t.Fatalf("yaml.Unmarshal(Config): %v", err) + } -rules: - end-of-file: - enable: true - disable-autofix: false - single-new-line: true -` + ignoreTests := []struct { + file string + ignore bool + }{ + {".git/objects/04/9f2973ffc85f71da1fd5a", true}, + {"README", false}, + {"clusters/mycluster/applications/app.yml", false}, + {"java/bin/myclass.class", false}, + } -func TestShouldIgnore_DefaultConf(t *testing.T) { for _, tt := range ignoreTests { t.Run(tt.file, func(t *testing.T) { - got := NewEndOfFileRule(autofixTestConf).ShouldIgnore(tt.file) + got := NewEndOfFileRule(c).ShouldIgnore(tt.file) want := tt.ignore if got != want { - t.Errorf("NewEndOfFileRule(defaultTestConf).ShouldIgnore(%q):\n\tExpected %v, got %v", tt.file, want, got) + t.Errorf( + "NewEndOfFileRule(c).ShouldIgnore(%q):\n\tExpected %v, got %v", + tt.file, want, got, + ) } }) } } -func TestShouldIgnore_YAMLParsedConf(t *testing.T) { +func TestShouldIgnore_MoreComplexConf(t *testing.T) { c := Config{} - err := yaml.Unmarshal([]byte(yamlAutofixTestConfig), &c) + err := yaml.Unmarshal( + []byte(`ignore: [ ".git/", "**/bin/", "applications", "/projects/" ]`), &c, + ) + if err != nil { t.Fatalf("yaml.Unmarshal(Config): %v", err) } + ignoreTests := []struct { + file string + ignore bool + }{ + {".git/objects/04/9f2973ffc85f71da1fd5a", true}, + {"README", false}, + {"clusters/mycluster/applications/app.yml", true}, + {"home/projects/data.md", false}, + {"projects/data.md", true}, + {"java/bin/myclass.class", true}, + } + for _, tt := range ignoreTests { t.Run(tt.file, func(t *testing.T) { got := NewEndOfFileRule(c).ShouldIgnore(tt.file) want := tt.ignore if got != want { - t.Errorf("NewEndOfFileRule(defaultTestConf).ShouldIgnore(%q):\n\tExpected %v, got %v", tt.file, want, got) + t.Errorf( + "NewEndOfFileRule(c).ShouldIgnore(%q):\n\tExpected %v, got %v", + tt.file, want, got, + ) } }) }