diff --git a/config.go b/config.go index 0de374d..b6e6055 100644 --- a/config.go +++ b/config.go @@ -14,6 +14,25 @@ const ( defaultConfigFile = ".gimps.yaml" ) +var ( + defaultExcludes = []string{ + // to not break 3rd party code + "vendor/**", + + // to not muck with generated files + "**/zz_generated.**", + "**/zz_generated_**", + "**/generated.pb.go", + "**/generated.proto", + "**/*_generated.go", + + // for performance + ".git/**", + "_build/**", + "node_modules/**", + } +) + type Config struct { gimps.Config `yaml:",inline"` Exclude []string `yaml:"exclude"` @@ -42,24 +61,7 @@ func loadConfiguration(filename string, moduleRoot string) (*Config, error) { } if c.Exclude == nil || len(c.Exclude) == 0 { - // vendor is because we never want to modify vendor, the - // others are just to save time while scanning bigger - // repositories that maybe also contain non-Go stuff - c.Exclude = []string{ - // to not break 3rd party code - "vendor/**", - - // to not muck with generated files - "**/zz_generated.**", - "**/zz_generated_**", - "**/generated.pb.go", - "**/*_generated.go", - - // for performance - ".git/**", - "_build/**", - "node_modules/**", - } + c.Exclude = defaultExcludes } if c.DetectGeneratedFiles == nil { diff --git a/fs.go b/fs.go new file mode 100644 index 0000000..6f42eb5 --- /dev/null +++ b/fs.go @@ -0,0 +1,132 @@ +package main + +import ( + "errors" + "go/parser" + "go/token" + "io/fs" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "strings" + + doublestar "github.com/bmatcuk/doublestar/v4" +) + +// listFiles takes a filename or directory as its start argument and returns +// a list of absolute file paths. If a filename is given, the list contains +// exactly one element, otherwise the directory is scanned recursively. +// Note that if start is a file, the skip rules are not evaluated. This allows +// users to force-format an otherwise skipped file. +func listFiles(start string, moduleRoot string, skips []string) ([]string, error) { + result := []string{} + + info, err := os.Stat(start) + if err != nil { + return nil, err + } + + if !info.IsDir() { + return []string{start}, nil + } + + err = filepath.WalkDir(start, func(path string, d fs.DirEntry, err error) error { + relPath, err := filepath.Rel(moduleRoot, path) + if err != nil { + return err + } + + if isSkipped(relPath, skips) { + if d.IsDir() { + return filepath.SkipDir + } else { + return nil + } + } + + if !d.IsDir() && strings.HasSuffix(path, ".go") { + result = append(result, path) + } + + return nil + }) + if err != nil { + return nil, err + } + + return result, nil +} + +func isSkipped(relPath string, skips []string) bool { + for _, skip := range skips { + if match, _ := doublestar.Match(skip, relPath); match { + return true + } + } + + return false +} + +func goModRootPath(path string) (string, error) { + // turn path into directory, if it's a file + if info, err := os.Stat(path); err == nil && !info.IsDir() { + path = filepath.Dir(path) + } + + for { + if fi, err := os.Stat(filepath.Join(path, "go.mod")); err == nil && !fi.IsDir() { + return path, nil + } + + d := filepath.Dir(path) + if d == path { + break + } + + path = d + } + + return "", errors.New("no go.mod found") +} + +var ( + // detect generated files by presence if this string in the first non-stripped line + generatedRe = regexp.MustCompile("(been generated|generated by|do not edit)") +) + +func isGeneratedFile(filename string) (bool, error) { + content, err := ioutil.ReadFile(filename) + if err != nil { + return false, err + } + + return isGeneratedCode(content) +} + +func isGeneratedCode(sourceCode []byte) (bool, error) { + fset := token.NewFileSet() + + file, err := parser.ParseFile(fset, "", sourceCode, parser.ParseComments) + if err != nil { + return false, err + } + + // go through all comments until we reach the package declaration +outer: + for _, commentGroup := range file.Comments { + for _, comment := range commentGroup.List { + // found the package declaration + if comment.Slash > file.Package { + break outer + } + + text := []byte(strings.ToLower(comment.Text)) + if generatedRe.Match(text) { + return true, nil + } + } + } + + return false, nil +} diff --git a/fs_test.go b/fs_test.go new file mode 100644 index 0000000..3eb491b --- /dev/null +++ b/fs_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "fmt" + "strings" + "testing" +) + +func TestDefaultExcludeFilterAgainstFilenames(t *testing.T) { + testcases := []struct { + filename string + expected bool + }{ + { + filename: "main.go", + expected: false, + }, + { + filename: "zz_generated.deepcopy.go", + expected: true, + }, + { + filename: "zz_generated.go", + expected: true, + }, + { + filename: "generated.pb.go", + expected: true, + }, + } + + for _, tt := range testcases { + t.Run(tt.filename, func(t *testing.T) { + skipped := isSkipped(tt.filename, defaultExcludes) + if skipped != tt.expected { + t.Errorf("Expected %v but got %v", tt.expected, skipped) + } + + tt.filename = "pkg/" + tt.filename + + skipped = isSkipped(tt.filename, defaultExcludes) + if skipped != tt.expected { + t.Errorf("Expected %v for %q, but got %v", tt.expected, tt.filename, skipped) + } + }) + } +} + +func TestIsGeneratedCode(t *testing.T) { + testcases := []struct { + comment string + expected bool + }{ + { + comment: "", + expected: false, + }, + { + comment: "// This file has been generated.", + expected: true, + }, + { + comment: "// Code generated by MockGen. DO NOT EDIT.", + expected: true, + }, + { + comment: "// Code generated by generate-imagename-constants.sh. DO NOT EDIT.", + expected: true, + }, + { + comment: "// This file has been generated with Velero v1.5.3. Do not edit.", + expected: true, + }, + } + + for i, tt := range testcases { + code := fmt.Sprintf(` +%s +package main + +func main() { + +} +`, tt.comment) + t.Run(fmt.Sprintf("#%d vanilla", i+1), runGeneratedCodeTest(code, tt.expected)) + + code = fmt.Sprintf(` +// +build foo + +%s + +package main + +func main() { + +} +`, tt.comment) + t.Run(fmt.Sprintf("#%d with build constraint", i+1), runGeneratedCodeTest(code, tt.expected)) + + code = fmt.Sprintf(` +// +build foo +/* + I am a license header. +*/ + +%s + +package main + +func main() { + +} +`, tt.comment) + t.Run(fmt.Sprintf("#%d with build constraint and license header", i+1), runGeneratedCodeTest(code, tt.expected)) + + code = fmt.Sprintf(` +// +build foo +/* + I am a license header. +*/ + +package main + +%s + +func main() { + +} +`, tt.comment) + t.Run(fmt.Sprintf("#%d, but too late, so ignore it", i+1), runGeneratedCodeTest(code, false)) + } +} + +func runGeneratedCodeTest(code string, expected bool) func(t *testing.T) { + return func(t *testing.T) { + b := []byte(strings.TrimSpace(code)) + + generated, err := isGeneratedCode(b) + if err != nil { + t.Errorf("should not have errored, but got %v", err) + } + + if generated != expected { + t.Errorf("Expected %v but got %v", expected, generated) + } + } +} diff --git a/main.go b/main.go index 84fa1e2..e366234 100644 --- a/main.go +++ b/main.go @@ -1,20 +1,15 @@ package main import ( - "bytes" - "errors" "flag" "fmt" - "io/fs" "io/ioutil" "log" "os" "path/filepath" - "regexp" "sort" "strings" - doublestar "github.com/bmatcuk/doublestar/v4" "github.com/incu6us/goimports-reviser/v2/pkg/module" "go.xrstf.de/gimps/pkg/gimps" @@ -88,7 +83,7 @@ func main() { for _, input := range inputs { filenames, err := listFiles(input, modRoot, config.Exclude) if err != nil { - log.Fatalf("Failed to list files in %q: %v", input, err) + log.Fatalf("Failed to process %q: %v", input, err) } for _, filename := range filenames { @@ -158,83 +153,3 @@ func cleanupArgs(args []string) ([]string, error) { return result, nil } - -func listFiles(start string, moduleRoot string, skips []string) ([]string, error) { - result := []string{} - - err := filepath.WalkDir(start, func(path string, d fs.DirEntry, err error) error { - relPath, err := filepath.Rel(moduleRoot, path) - if err != nil { - return err - } - - for _, skip := range skips { - if match, _ := doublestar.Match(skip, relPath); match { - if d.IsDir() { - return filepath.SkipDir - } else { - return nil - } - } - } - - if !d.IsDir() && strings.HasSuffix(path, ".go") { - result = append(result, path) - } - - return nil - }) - if err != nil { - return nil, err - } - - return result, nil -} - -func goModRootPath(path string) (string, error) { - // turn path into directory, if it's a file - if info, err := os.Stat(path); err == nil && !info.IsDir() { - path = filepath.Dir(path) - } - - for { - if fi, err := os.Stat(filepath.Join(path, "go.mod")); err == nil && !fi.IsDir() { - return path, nil - } - - d := filepath.Dir(path) - if d == path { - break - } - - path = d - } - - return "", errors.New("no go.mod found") -} - -// This is based on https://github.com/kubermatic-labs/boilerplate, -// Apache License 2.0, (c) 2020 Kubermatic - -var ( - // detect generated files by presence if this string in the first non-stripped line - generatedRe = regexp.MustCompile("(been generated|generated by|do not edit)") - - // strip // +build \n\n build constraints - goBuildConstraintsRe = regexp.MustCompile(`^(// \+build.*\n)+\n*`) -) - -func isGeneratedFile(filename string) (bool, error) { - content, err := ioutil.ReadFile(filename) - if err != nil { - return false, err - } - - // trim build constraints - content = goBuildConstraintsRe.ReplaceAll(content, nil) - - // check the first line - lines := bytes.SplitN(content, []byte{'\n'}, 2) - - return generatedRe.Match(bytes.ToLower(lines[0])), nil -}