diff --git a/.editorconfig b/.editorconfig index 40672e9..04f5e64 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,6 +7,13 @@ indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true +max_line_length = 120 + +[*.md] +max_line_length = off + +[{LICENSE,go.*}] +max_line_length = unset [Dockerfile] indent_size = 4 diff --git a/.golangci.yml b/.golangci.yml index 279c45e..2e04318 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,4 +23,5 @@ linters: - structcheck - unused - varcheck + - gosec disable-all: true diff --git a/README.md b/README.md index a8d2a59..8a3de03 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,14 @@ $ eclint -exclude "testdata/**/*" - `indent_size` - `indent_style` - `insert_final_newline` +- `max_line_length` (when using tabs, specify the `tab_width` or `indent_size`) - `trim_trailing_whitespace` -- [domain-specific properties](https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties#ideas-for-domain-specific-properties) +- [domain-specific properties][dsl] - `line_comment` - `block_comment_start`, `block_comment`, `block_comment_end` + +### More + - when not path is given, it searches for files via `git ls-files` - `-exclude` to filter out some files - unset / alter properties via the `eclint_` prefix @@ -69,3 +73,5 @@ The methodology is to run the linter against some big repositories `time $(eclin - [golangci-lint](https://github.com/golangci/golangci-lint), Go linters - [goreleaser](https://goreleaser.com/) - [klogr](https://github.com/kubernetes/klog/tree/master/klogr) + +[dsl]: https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties#ideas-for-domain-specific-properties diff --git a/lint.go b/lint.go index e72573c..ff13f93 100644 --- a/lint.go +++ b/lint.go @@ -12,6 +12,9 @@ import ( "github.com/go-logr/logr" ) +// DefaultTabWidth sets the width of a tab used when counting the line length +const DefaultTabWidth = 8 + // validate is where the validations rules are applied func validate(r io.Reader, log logr.Logger, def *editorconfig.Definition) []error { //nolint:gocyclo var buf *bytes.Buffer @@ -21,6 +24,7 @@ func validate(r io.Reader, log logr.Logger, def *editorconfig.Definition) []erro indentSize, _ := strconv.Atoi(def.IndentSize) var lastLine []byte + var lastIndex int var insideBlockComment bool var blockCommentStart []byte @@ -43,6 +47,19 @@ func validate(r io.Reader, log logr.Logger, def *editorconfig.Definition) []erro } } + maxLength := 0 + tabWidth := def.TabWidth + if mll, ok := def.Raw["max_line_length"]; ok && mll != "off" && mll != "unset" { + ml, err := strconv.Atoi(mll) + if err != nil || ml < 0 { + return []error{fmt.Errorf("max_line_length expected a non-negative number, got %s", mll)} + } + maxLength = ml + if tabWidth <= 0 { + tabWidth = DefaultTabWidth + } + } + errs := readLines(r, func(index int, data []byte) error { var err error @@ -64,15 +81,17 @@ func validate(r io.Reader, log logr.Logger, def *editorconfig.Definition) []erro // XXX not so nice hack if ve, ok := err.(validationError); ok { ve.line = lastLine - ve.index = index - 1 + ve.index = lastIndex lastLine = data + lastIndex = index return ve } } lastLine = data + lastIndex = index if buf != nil && buf.Len() < bufSize { if _, err := buf.Write(data); err != nil { @@ -80,7 +99,7 @@ func validate(r io.Reader, log logr.Logger, def *editorconfig.Definition) []erro } } - if err == nil && def.IndentStyle != "" && def.IndentStyle != "unset" { + if def.IndentStyle != "" && def.IndentStyle != "unset" { if insideBlockComment && blockCommentEnd != nil { insideBlockComment = !isBlockCommentEnd(blockCommentEnd, data) } @@ -102,43 +121,58 @@ func validate(r io.Reader, log logr.Logger, def *editorconfig.Definition) []erro err = trimTrailingWhitespace(data) } + if err == nil && maxLength > 0 && tabWidth > 0 { + err = maxLineLength(maxLength, tabWidth, data) + } + // Enrich the error with the line number - if err != nil { - if ve, ok := err.(validationError); ok { - ve.line = data - ve.index = index - return ve - } - return err + if ve, ok := err.(validationError); ok { + ve.line = data + ve.index = index + return ve } - return nil + return err }) if buf != nil && buf.Len() > 0 { err := charset(def.Charset, buf.Bytes()) - errs = append(errs, err) + if err != nil { + errs = append(errs, err) + } } if lastLine != nil && def.InsertFinalNewline != nil { + var err error var lastChar byte if len(lastLine) > 0 { lastChar = lastLine[len(lastLine)-1] } - if lastChar != 0x0 && lastChar != '\r' && lastChar != '\n' { + if lastChar != 0x0 && lastChar != cr && lastChar != lf { if *def.InsertFinalNewline { - err := fmt.Errorf("missing the final newline") - errs = append(errs, err) + err = fmt.Errorf("missing the final newline") } } else { if def.EndOfLine != "" { - err := endOfLine(def.EndOfLine, lastLine) - errs = append(errs, err) + err = endOfLine(def.EndOfLine, lastLine) + } + + if err != nil { + if !*def.InsertFinalNewline { + err = fmt.Errorf("found an extraneous final newline") + } else { + err = nil + } } + } - if !*def.InsertFinalNewline { - err := fmt.Errorf("found an extraneous final newline") + if err != nil { + if ve, ok := err.(validationError); ok { + ve.line = lastLine + ve.index = lastIndex + errs = append(errs, ve) + } else { errs = append(errs, err) } } diff --git a/main.go b/main.go index 2086894..831b41b 100644 --- a/main.go +++ b/main.go @@ -35,7 +35,7 @@ func walk(paths ...string) ([]string, error) { } mode := i.Mode() if mode.IsRegular() && !mode.IsDir() { - log.V(4).Info("index %s\n", p) + log.V(4).Info("index %s", p) files = append(files, p) } return nil @@ -88,7 +88,12 @@ func main() { flag.BoolVar(&flagVersion, "version", false, "print the version number") flag.BoolVar(&noColors, "no_colors", false, "enable or disable colors") flag.BoolVar(&summary, "summary", false, "enable the summary view") - flag.BoolVar(&showAllErrors, "show_all_errors", false, fmt.Sprintf("display all errors for each file (otherwise %d are kept)", showErrorQuantity)) + flag.BoolVar( + &showAllErrors, + "show_all_errors", + false, + fmt.Sprintf("display all errors for each file (otherwise %d are kept)", showErrorQuantity), + ) flag.StringVar(&exclude, "exclude", "", "paths to exclude") flag.Parse() @@ -138,7 +143,9 @@ func main() { if ve, ok := err.(validationError); ok { log.V(4).Info("lint error", "error", ve) if !summary { - fmt.Fprintf(stdout, "%s:%s: %s\n", au.Green(strconv.Itoa(ve.index)), au.Green(strconv.Itoa(ve.position)), ve.error) + vi := au.Green(strconv.Itoa(ve.index)) + vp := au.Green(strconv.Itoa(ve.position)) + fmt.Fprintf(stdout, "%s:%s: %s\n", vi, vp, ve.error) l, err := errorAt(au, ve.line, ve.position-1) if err != nil { log.Error(err, "line formating failure", "error", ve) @@ -152,7 +159,10 @@ func main() { } if d >= showErrorQuantity && len(errs) > d { - fmt.Fprintln(stdout, fmt.Sprintf(" ... skipping at most %s errors", au.BrightRed(strconv.Itoa(len(errs)-d)))) + fmt.Fprintln( + stdout, + fmt.Sprintf(" ... skipping at most %s errors", au.BrightRed(strconv.Itoa(len(errs)-d))), + ) break } @@ -182,13 +192,14 @@ func errorAt(au aurora.Aurora, line []byte, position int) (string, error) { } for i := 0; i < position; i++ { - if line[i] != '\r' && line[i] != '\n' { + if line[i] != cr && line[i] != lf { if err := b.WriteByte(line[i]); err != nil { return "", err } } } + // XXX this will break every non latin1 line. s := " " if position < len(line)-1 { s = string(line[position : position+1]) @@ -198,7 +209,7 @@ func errorAt(au aurora.Aurora, line []byte, position int) (string, error) { } for i := position + 1; i < len(line); i++ { - if line[i] != '\r' && line[i] != '\n' { + if line[i] != cr && line[i] != lf { if err := b.WriteByte(line[i]); err != nil { return "", err } diff --git a/scanner.go b/scanner.go index 62237ce..3357090 100644 --- a/scanner.go +++ b/scanner.go @@ -14,13 +14,13 @@ type lineFunc func(int, []byte) error func splitLines(data []byte, atEOF bool) (int, []byte, error) { i := 0 for i < len(data) { - if data[i] == '\r' { + if data[i] == cr { i++ - if i < len(data) && data[i] == '\n' { + if i < len(data) && data[i] == lf { i++ } return i, data[0:i], nil - } else if data[i] == '\n' { + } else if data[i] == lf { i++ return i, data[0:i], nil } @@ -40,7 +40,9 @@ func splitLines(data []byte, atEOF bool) (int, []byte, error) { // readLines consumes the reader and emit each line via the lineFunc // -// Line numbering starts at 1 +// Line numbering starts at 1. Scanner is pretty smart an will reuse +// its memory structure. This is somehing we explicitly avoid by copying +// the content to a new slice. func readLines(r io.Reader, fn lineFunc) []error { errs := make([]error, 0) sc := bufio.NewScanner(r) @@ -48,7 +50,9 @@ func readLines(r io.Reader, fn lineFunc) []error { i := 1 for sc.Scan() { - line := sc.Bytes() + l := sc.Bytes() + line := make([]byte, len(l)) + copy(line, l) if err := fn(i, line); err != nil { errs = append(errs, err) } diff --git a/validators.go b/validators.go index 21a1303..33b6c9a 100644 --- a/validators.go +++ b/validators.go @@ -7,6 +7,13 @@ import ( "github.com/gogs/chardet" ) +const ( + cr = '\r' + lf = '\n' + tab = '\t' + space = ' ' +) + // validationError is a rich type containing information about the error type validationError struct { error string @@ -16,6 +23,10 @@ type validationError struct { position int } +func (e validationError) String() string { + return e.Error() +} + // Error builds the error string. func (e validationError) Error() string { return fmt.Sprintf("%d:%d: %s", e.index, e.position, e.error) @@ -25,21 +36,21 @@ func (e validationError) Error() string { func endOfLine(eol string, data []byte) error { switch eol { case "lf": - if !bytes.HasSuffix(data, []byte{'\n'}) || bytes.HasSuffix(data, []byte{'\r', '\n'}) { + if !bytes.HasSuffix(data, []byte{lf}) || bytes.HasSuffix(data, []byte{cr, lf}) { return validationError{ error: "line does not end with lf (`\\n`)", position: len(data), } } case "crlf": - if !bytes.HasSuffix(data, []byte{'\r', '\n'}) { + if !bytes.HasSuffix(data, []byte{cr, lf}) { return validationError{ error: "line does not end with crlf (`\\r\\n`)", position: len(data), } } case "cr": - if !bytes.HasSuffix(data, []byte{'\r'}) { + if !bytes.HasSuffix(data, []byte{cr}) { return validationError{ error: "line does not end with cr (`\\r`)", position: len(data), @@ -100,7 +111,7 @@ func charset(charset string, data []byte) error { return nil } default: - return fmt.Errorf("%q is an invalid value for charset or should have been detected using its BOM already", charset) + return fmt.Errorf("charset %q is invalid or should have been detected using its BOM already", charset) } } @@ -119,11 +130,11 @@ func indentStyle(style string, size int, data []byte) error { var x byte switch style { case "space": - c = ' ' - x = '\t' + c = space + x = tab case "tab": - c = '\t' - x = ' ' + c = tab + x = space size = 1 case "unset": return nil @@ -141,7 +152,7 @@ func indentStyle(style string, size int, data []byte) error { position: i + 1, } } - if data[i] == '\r' || data[i] == '\n' || (size > 0 && i%size == 0) { + if data[i] == cr || data[i] == lf || (size > 0 && i%size == 0) { break } return validationError{ @@ -156,10 +167,10 @@ func indentStyle(style string, size int, data []byte) error { // trimTrailingWhitespace func trimTrailingWhitespace(data []byte) error { for i := len(data) - 1; i >= 0; i-- { - if data[i] == '\r' || data[i] == '\n' { + if data[i] == cr || data[i] == lf { continue } - if data[i] == ' ' || data[i] == '\t' { + if data[i] == space || data[i] == tab { return validationError{ error: "line has some trailing whitespaces", position: i + 1, @@ -173,7 +184,7 @@ func trimTrailingWhitespace(data []byte) error { // isBlockCommentStart tells you when a block comment started on this line func isBlockCommentStart(start []byte, data []byte) bool { for i := 0; i < len(data); i++ { - if data[i] == ' ' || data[i] == '\t' { + if data[i] == space || data[i] == tab { continue } return bytes.HasPrefix(data[i:], start) @@ -184,12 +195,12 @@ func isBlockCommentStart(start []byte, data []byte) bool { // checkBlockComment checks the line is a valid block comment func checkBlockComment(i int, prefix []byte, data []byte) error { for ; i < len(data); i++ { - if data[i] == ' ' || data[i] == '\t' { + if data[i] == space || data[i] == tab { continue } if !bytes.HasPrefix(data[i:], prefix) { return validationError{ - error: fmt.Sprintf("the block_comment prefix %q was expected inside a block comment", string(prefix)), + error: fmt.Sprintf("block_comment prefix %q was expected inside a block comment", string(prefix)), position: i + 1, } } @@ -201,10 +212,38 @@ func checkBlockComment(i int, prefix []byte, data []byte) error { // isBlockCommentEnd tells you when a block comment end on this line func isBlockCommentEnd(end []byte, data []byte) bool { for i := len(data) - 1; i > 0; i-- { - if data[i] == '\r' || data[i] == '\n' { + if data[i] == cr || data[i] == lf { continue } return bytes.HasSuffix(data[:i], end) } return false } + +// maxLineLength checks the length of a given line +func maxLineLength(maxLength int, tabWidth int, data []byte) error { + length := 0 + breakingPosition := 0 + for i := 0; i < len(data); i++ { + if data[i] == cr || data[i] == lf { + break + } + if data[i] == tab { + length += tabWidth + } else { + length++ + } + if length > maxLength && breakingPosition == 0 { + breakingPosition = i + } + } + + if length > maxLength { + return validationError{ + error: fmt.Sprintf("line is too long (%d > %d)", length+1, maxLength), + position: breakingPosition, + } + } + + return nil +} diff --git a/validators_test.go b/validators_test.go index b631faf..3bdfda3 100644 --- a/validators_test.go +++ b/validators_test.go @@ -87,7 +87,7 @@ func TestCharset(t *testing.T) { r := bytes.NewReader(tc.File) for _, err := range validate(r, l, def) { if err != nil { - t.Errorf("no errors where expected, got %s", err) + t.Errorf("no errors were expected, got %s", err) } } }) @@ -120,7 +120,7 @@ func TestEndOfLine(t *testing.T) { t.Parallel() err := endOfLine(tc.EndOfLine, tc.Line) if err != nil { - t.Errorf("no errors where expected, got %s", err) + t.Errorf("no errors were expected, got %s", err) } }) } @@ -221,7 +221,7 @@ func TestTrimTrailingWhitespace(t *testing.T) { t.Parallel() err := trimTrailingWhitespace(tc.Line) if err != nil { - t.Errorf("no errors where expected, got %s", err) + t.Errorf("no errors were expected, got %s", err) } }) } @@ -292,7 +292,7 @@ func TestIndentStyle(t *testing.T) { t.Parallel() err := indentStyle(tc.IndentStyle, tc.IndentSize, tc.Line) if err != nil { - t.Errorf("no errors where expected, got %s", err) + t.Errorf("no errors were expected, got %s", err) } }) } @@ -369,7 +369,79 @@ func TestCheckBlockComment(t *testing.T) { t.Parallel() err := checkBlockComment(tc.Position, tc.Prefix, tc.Line) if err != nil { - t.Errorf("no errors where expected, got %s", err) + t.Errorf("no errors were expected, got %s", err) + } + }) + } +} + +func TestMaxLineLength(t *testing.T) { + tests := []struct { + Name string + MaxLineLength int + TabWidth int + Line []byte + }{ + { + Name: "no limits", + MaxLineLength: 0, + TabWidth: 0, + Line: []byte("\r\n"), + }, { + Name: "some limit", + MaxLineLength: 1, + TabWidth: 0, + Line: []byte(".\r\n"), + }, { + Name: "some limit", + MaxLineLength: 10, + TabWidth: 0, + Line: []byte("0123456789\n"), + }, { + Name: "tabs", + MaxLineLength: 5, + TabWidth: 2, + Line: []byte("\t\t.\n"), + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + err := maxLineLength(tc.MaxLineLength, tc.TabWidth, tc.Line) + if err != nil { + t.Errorf("no errors were expected, got %s", err) + } + }) + } +} + +func TestMaxLineLengthFailure(t *testing.T) { + tests := []struct { + Name string + MaxLineLength int + TabWidth int + Line []byte + }{ + { + Name: "small limit", + MaxLineLength: 1, + TabWidth: 1, + Line: []byte("..\r\n"), + }, { + Name: "small limit and tab", + MaxLineLength: 2, + TabWidth: 2, + Line: []byte("\t.\r\n"), + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + err := maxLineLength(tc.MaxLineLength, tc.TabWidth, tc.Line) + if err == nil { + t.Error("an error was expected") } }) }