diff --git a/_fixtures/editorconfig_max_len.go b/_fixtures/editorconfig_max_len.go new file mode 100644 index 0000000..86c1db2 --- /dev/null +++ b/_fixtures/editorconfig_max_len.go @@ -0,0 +1,20 @@ +package fixtures + +import ( + "fmt" + "time" +) + +func _() { + veryLongString := fmt.Sprintf("The current time is %v and %d, %d, %d, %d, %d, %d", time.Now(), 1, 2, 3, 4, 5, 6) + anotherLongString := fmt.Sprintf("The current time is %v and %d, %d, %d, %d, %d, %d, %d, %d, %d", time.Now(), 1, 2, 3, 4, 5, 6, 7, 8, 9) + + extremelyLongSlice := []string{"This is the first very long element in a slice", "This is the second very long element that contains a lot of text to make the line exceed 120 characters", "And this is the third one"} + + someVeryLongMap := map[string]string{"ThisIsAVeryLongKeyThatWillMakeThisLineExceed120Characters": "AndThisIsAnEquallyLongValueThatWillContributeToMakingThisLineVeryVeryLongIndeed"} + + _ = veryLongString + _ = anotherLongString + _ = extremelyLongSlice + _ = someVeryLongMap +} diff --git a/_fixtures/editorconfig_max_len__exp.go b/_fixtures/editorconfig_max_len__exp.go new file mode 100644 index 0000000..cd18eef --- /dev/null +++ b/_fixtures/editorconfig_max_len__exp.go @@ -0,0 +1,38 @@ +package fixtures + +import ( + "fmt" + "time" +) + +func _() { + veryLongString := fmt.Sprintf("The current time is %v and %d, %d, %d, %d, %d, %d", time.Now(), 1, 2, 3, 4, 5, 6) + anotherLongString := fmt.Sprintf( + "The current time is %v and %d, %d, %d, %d, %d, %d, %d, %d, %d", + time.Now(), + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ) + + extremelyLongSlice := []string{ + "This is the first very long element in a slice", + "This is the second very long element that contains a lot of text to make the line exceed 120 characters", + "And this is the third one", + } + + someVeryLongMap := map[string]string{ + "ThisIsAVeryLongKeyThatWillMakeThisLineExceed120Characters": "AndThisIsAnEquallyLongValueThatWillContributeToMakingThisLineVeryVeryLongIndeed", + } + + _ = veryLongString + _ = anotherLongString + _ = extremelyLongSlice + _ = someVeryLongMap +} diff --git a/go.mod b/go.mod index 5f8ba4c..3aa4064 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,13 @@ module github.com/segmentio/golines -go 1.21 +go 1.22.0 + +toolchain go1.24.2 require ( github.com/dave/dst v0.27.3 github.com/dave/jennifer v1.7.0 + github.com/editorconfig/editorconfig-core-go/v2 v2.6.3 github.com/fatih/structtag v1.2.0 github.com/pmezard/go-difflib v1.0.0 github.com/sirupsen/logrus v1.9.3 @@ -24,9 +27,10 @@ require ( github.com/onsi/ginkgo v1.10.2 // indirect github.com/onsi/gomega v1.7.0 // indirect golang.org/x/crypto v0.31.0 // indirect - golang.org/x/mod v0.14.0 // indirect + golang.org/x/mod v0.23.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/tools v0.17.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 75ccb66..3b9a86b 100644 --- a/go.sum +++ b/go.sum @@ -9,10 +9,14 @@ github.com/dave/jennifer v1.7.0/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/editorconfig/editorconfig-core-go/v2 v2.6.3 h1:XVUp6qW3BIkmM3/1EkrHpa6bL56APOynfXcZEmIgOhs= +github.com/editorconfig/editorconfig-core-go/v2 v2.6.3/go.mod h1:ThHVc+hqbUsmE1wmK/MASpQEhCleWu1JDJDNhUOMy0c= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -42,8 +46,8 @@ github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJ github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= +golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= @@ -69,6 +73,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go index 1e5fd6c..0c5cb57 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,8 @@ import ( kingpin "gopkg.in/alecthomas/kingpin.v2" ) +const defaultMaxLen = 100 + var ( // these values are provided automatically by Goreleaser // ref: https://goreleaser.com/customization/builds/ @@ -53,7 +55,7 @@ var ( "List files that would be reformatted by this tool").Short('l').Default("false").Bool() maxLen = kingpin.Flag( "max-len", - "Target maximum line length").Short('m').Default("100").Int() + fmt.Sprintf("Maximum line length, or .editorconfig or default to %d", defaultMaxLen)).Short('m').Int() profile = kingpin.Flag( "profile", "Path to profile output").Default("").String() @@ -118,6 +120,7 @@ func main() { func run() error { config := ShortenerConfig{ MaxLen: *maxLen, + CurrentMaxLen: 0, TabLen: *tabLen, KeepAnnotations: *keepAnnotations, ShortenComments: *shortenComments, @@ -127,6 +130,7 @@ func run() error { BaseFormatterCmd: *baseFormatterCmd, ChainSplitDots: *chainSplitDots, } + shortener := NewShortener(config) if len(*paths) == 0 { @@ -219,7 +223,9 @@ func processFile(shortener *Shortener, path string) ([]byte, []byte, error) { return nil, nil, err } + shortener.SetCurrentMaxLen(path) result, err := shortener.Shorten(contents) + return contents, result, err } @@ -258,5 +264,4 @@ func handleOutput(path string, contents []byte, result []byte) error { fmt.Print(string(result)) return nil - } diff --git a/shortener.go b/shortener.go index b3a14e8..443bce2 100644 --- a/shortener.go +++ b/shortener.go @@ -10,10 +10,12 @@ import ( "os/exec" "reflect" "regexp" + "strconv" "strings" "github.com/dave/dst" "github.com/dave/dst/decorator" + editorconfig "github.com/editorconfig/editorconfig-core-go/v2" log "github.com/sirupsen/logrus" ) @@ -36,6 +38,7 @@ const maxRounds = 20 // ShortenerConfig stores the configuration options exposed by a Shortener instance. type ShortenerConfig struct { MaxLen int // Max target width for each line + CurrentMaxLen int // Max target width for each line for the current file or context TabLen int // Width of a tab character KeepAnnotations bool // Whether to keep annotations in final result (for debugging only) ShortenComments bool // Whether to shorten comments @@ -86,9 +89,43 @@ func NewShortener(config ShortenerConfig) *Shortener { s.baseFormatterArgs = []string{} } + s.config.CurrentMaxLen = s.config.MaxLen + if s.config.CurrentMaxLen == 0 { + s.config.CurrentMaxLen = defaultMaxLen + } + return s } +func (s *Shortener) SetCurrentMaxLen(path string) { + // defined by user + if s.config.MaxLen != 0 { + s.config.CurrentMaxLen = s.config.MaxLen + + return + } + + // Try to get .editorconfig definition for the given path + def, err := editorconfig.GetDefinitionForFilename(path) + if err != nil { + s.config.CurrentMaxLen = defaultMaxLen + + return + } + + defMaxLen, ok := def.Raw["max_line_length"] + if ok && defMaxLen != editorconfig.UnsetValue { + defMaxLenInt, err := strconv.Atoi(defMaxLen) + if err == nil { + s.config.CurrentMaxLen = defMaxLenInt + + return + } + } + + s.config.CurrentMaxLen = defaultMaxLen +} + // Shorten shortens the provided golang file content bytes. func (s *Shortener) Shorten(contents []byte) ([]byte, error) { if s.config.IgnoreGenerated && s.isGenerated(contents) { @@ -101,7 +138,7 @@ func (s *Shortener) Shorten(contents []byte) ([]byte, error) { // Do initial, non-line-length-aware formatting contents, err = s.formatSrc(contents) if err != nil { - return nil, fmt.Errorf("Error formatting source: %+v", err) + return nil, fmt.Errorf("error formatting source: %+v", err) } for { @@ -234,7 +271,7 @@ func (s *Shortener) annotateLongLines(lines []string) ([]string, int) { length := s.lineLen(line) if prevLen > -1 { - if length <= s.config.MaxLen { + if length <= s.config.CurrentMaxLen { // Shortening successful, remove previous annotation annotatedLines = annotatedLines[:len(annotatedLines)-1] } else if length < prevLen { @@ -242,7 +279,7 @@ func (s *Shortener) annotateLongLines(lines []string) ([]string, int) { annotatedLines[len(annotatedLines)-1] = CreateAnnotation(length) linesToShorten++ } - } else if !s.isComment(line) && length > s.config.MaxLen { + } else if !s.isComment(line) && length > s.config.CurrentMaxLen { annotatedLines = append( annotatedLines, CreateAnnotation(length), @@ -282,7 +319,7 @@ func (s *Shortener) shortenCommentsFunc(contents []byte) []byte { for _, line := range lines { if s.isComment(line) && !IsAnnotation(line) && !s.isGoDirective(line) && - s.lineLen(line) > s.config.MaxLen { + s.lineLen(line) > s.config.CurrentMaxLen { start := strings.Index(line, "//") prefix = line[0:(start + 2)] trimmedLine := strings.Trim(line[(start+2):], " ") @@ -292,7 +329,7 @@ func (s *Shortener) shortenCommentsFunc(contents []byte) []byte { // Reflow the accumulated `words` before appending the unprocessed `line`. currLineLen := 0 currLineWords := []string{} - maxCommentLen := s.config.MaxLen - s.lineLen(prefix) + maxCommentLen := s.config.CurrentMaxLen - s.lineLen(prefix) for _, word := range words { if currLineLen > 0 && currLineLen+1+len(word) > maxCommentLen { cleanedLines = append( diff --git a/shortener_test.go b/shortener_test.go index eefd805..e036071 100644 --- a/shortener_test.go +++ b/shortener_test.go @@ -1,12 +1,14 @@ package main import ( + "fmt" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const fixturesDir = "_fixtures" @@ -27,6 +29,8 @@ func TestShortener(t *testing.T) { continue } else if strings.HasSuffix(fileInfo.Name(), "__exp.go") { continue + } else if strings.HasPrefix(fileInfo.Name(), "editorconfig_") { + continue } fixturePaths = append( @@ -56,22 +60,12 @@ func TestShortener(t *testing.T) { ) for _, fixturePath := range fixturePaths { - contents, err := os.ReadFile(fixturePath) - if err != nil { - t.Fatalf( - "Unexpected error reading fixture %s: %+v", - fixturePath, - err, - ) - } - - shortenedContents, err := shortener.Shorten(contents) - assert.Nil(t, err) + shortenedContents := generateShortenedContents(t, shortener, fixturePath) expectedPath := fixturePath[0:len(fixturePath)-3] + "__exp" + ".go" if os.Getenv("REGENERATE_TEST_OUTPUTS") == "true" { - err := os.WriteFile(expectedPath, shortenedContents, 0644) + err := os.WriteFile(expectedPath, shortenedContents, 0o644) if err != nil { t.Fatalf( "Unexpected error writing output file %s: %+v", @@ -81,15 +75,224 @@ func TestShortener(t *testing.T) { } } - expectedContents, err := os.ReadFile(expectedPath) - if err != nil { - t.Fatalf( - "Unexpected error reading expected file %s: %+v", - expectedPath, - err, - ) - } + expectedContents := readExpectedContents(t, fixturePath) assert.Equal(t, string(expectedContents), string(shortenedContents)) } } + +func generateShortenedContents(t *testing.T, shortener *Shortener, path string) []byte { + t.Helper() + + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf( + "Unexpected error reading fixture %s: %+v", + path, + err, + ) + } + + shortenedContents, err := shortener.Shorten(contents) + assert.Nil(t, err) + + return shortenedContents +} + +func readExpectedContents(t *testing.T, path string) []byte { + t.Helper() + + expectedPath := path[0:len(path)-3] + "__exp" + ".go" + + expectedContents, err := os.ReadFile(expectedPath) + if err != nil { + t.Fatalf( + "Unexpected error reading expected file %s: %+v", + expectedPath, + err, + ) + } + + return expectedContents +} + +func TestShortenerWithEditorConfig(t *testing.T) { + createEditorconfigFile(t, 150, 120, true) + defer restoreEditorconfigFile(t) + + dotDir, err := os.MkdirTemp("", "dot") + if err != nil { + t.Fatalf("Error creating output directory for dot files: %+v", err) + } + defer os.RemoveAll(dotDir) + + shortener := NewShortener( + ShortenerConfig{ + MaxLen: 0, + CurrentMaxLen: 0, + TabLen: 4, + KeepAnnotations: false, + ShortenComments: true, + ReformatTags: true, + IgnoreGenerated: true, + BaseFormatterCmd: "gofmt", + DotFile: filepath.Join(dotDir, "out.dot"), + ChainSplitDots: true, + }, + ) + // When creating the shortener, the current max length is set to defaultMaxLen + assert.Equal(t, defaultMaxLen, shortener.config.CurrentMaxLen) + + fixturePath := filepath.Join(fixturesDir, "editorconfig_max_len.go") + + shortener.SetCurrentMaxLen(fixturePath) + shortenedContents := generateShortenedContents(t, shortener, fixturePath) + + expectedContents := readExpectedContents(t, fixturePath) + + assert.Equal(t, string(expectedContents), string(shortenedContents)) +} + +func TestCurrentMaxLen(t *testing.T) { + t.Run("maxlen given by user", func(t *testing.T) { + userMaxLen := 110 + + shortener := NewShortener(ShortenerConfig{ + MaxLen: userMaxLen, + }) + + t.Run("without .editorconfig I should get max length from user", func(t *testing.T) { + shortener.SetCurrentMaxLen("afile.go") + assert.Equal(t, userMaxLen, shortener.config.CurrentMaxLen) + }) + + t.Run("with .editorconfig I should get max length from user", func(t *testing.T) { + createEditorconfigFile(t, 150, 120, true) + shortener.SetCurrentMaxLen("anotherfile.go") + assert.Equal(t, userMaxLen, shortener.config.CurrentMaxLen) + restoreEditorconfigFile(t) + }) + }) + + t.Run("no maxlen given by user", func(t *testing.T) { + shortener := NewShortener(ShortenerConfig{ + MaxLen: 0, + TabLen: 4, + KeepAnnotations: false, + ShortenComments: true, + ReformatTags: true, + IgnoreGenerated: true, + BaseFormatterCmd: "gofmt", + DotFile: "", + ChainSplitDots: true, + }) + + t.Run("without .editorconfig I should get default max length", func(t *testing.T) { + shortener.SetCurrentMaxLen("/tmp") + assert.Equal(t, defaultMaxLen, shortener.config.CurrentMaxLen) + }) + + t.Run("with .editorconfig I should get max length from .editorconfig", func(t *testing.T) { + createEditorconfigFile(t, 150, 0, true) + shortener.SetCurrentMaxLen("afile.go") + assert.Equal(t, 150, shortener.config.CurrentMaxLen) + restoreEditorconfigFile(t) + }) + + t.Run("with Go section in .editorconfig I should get its max length", func(t *testing.T) { + createEditorconfigFile(t, 150, 120, true) + shortener.SetCurrentMaxLen("afile.go") + assert.Equal(t, 120, shortener.config.CurrentMaxLen) + restoreEditorconfigFile(t) + }) + + t.Run("with invalid .editorconfig I should get default max length", func(t *testing.T) { + createEditorconfigFile(t, 0, 0, false) + shortener.SetCurrentMaxLen("afile.go") + assert.Equal(t, defaultMaxLen, shortener.config.CurrentMaxLen) + restoreEditorconfigFile(t) + }) + }) +} + +func existsEditorconfigFile(t *testing.T, filename string) bool { + t.Helper() + + pwd, err := os.Getwd() + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(pwd, filename)) + if os.IsNotExist(err) { + return false + } + require.NoError(t, err) + + return true +} + +func backupEditorconfigFile(t *testing.T) { + t.Helper() + + pwd, err := os.Getwd() + require.NoError(t, err) + + err = os.Rename(filepath.Join(pwd, editorconfigFilename), filepath.Join(pwd, editorconfigBackupFilename)) + require.NoError(t, err) +} + +func createEditorconfigFile(t *testing.T, maxLen int, goSectionMaxLen int, valid bool) { + t.Helper() + + if existsEditorconfigFile(t, editorconfigFilename) { + backupEditorconfigFile(t) + } + + contents := make([]string, 0) + + if valid { + contents = append(contents, `# EditorConfig is awesome: https://editorconfig.org`) + contents = append(contents, `# top-most EditorConfig file`, ``) + contents = append(contents, `root = true`, ``, `[*]`) + contents = append(contents, fmt.Sprintf(`max_line_length = %d`, maxLen)) + + if goSectionMaxLen > 0 { + contents = append(contents, ``, `[*.go]`) + contents = append(contents, fmt.Sprintf("max_line_length = %d", goSectionMaxLen)) + } + } else { + contents = append(contents, ``) + } + + pwd, err := os.Getwd() + require.NoError(t, err) + + file, err := os.Create(filepath.Join(pwd, editorconfigFilename)) + require.NoError(t, err) + + _, err = file.WriteString(strings.Join(contents, "\n")) + require.NoError(t, err) + + err = file.Close() + require.NoError(t, err) +} + +func restoreEditorconfigFile(t *testing.T) { + t.Helper() + + pwd, err := os.Getwd() + require.NoError(t, err) + + if existsEditorconfigFile(t, editorconfigBackupFilename) { + err = os.Rename(filepath.Join(pwd, editorconfigBackupFilename), filepath.Join(pwd, editorconfigFilename)) + require.NoError(t, err) + return + } + + err = os.Remove(filepath.Join(pwd, editorconfigFilename)) + require.NoError(t, err) +} + +const ( + editorconfigFilename = ".editorconfig" + editorconfigBackupFilename = ".editorconfig-backup" +)