From 0e4998bb4f605e8d742f430db0a178bd510a7a92 Mon Sep 17 00:00:00 2001 From: golangci Date: Sat, 5 May 2018 07:45:06 +0300 Subject: [PATCH] move code from golangci-worker to golangci-lint --- .gitignore | 1 + Gopkg.lock | 150 +++++++++++++ Gopkg.toml | 50 +++++ cmd/golangci-lint/main.go | 43 ++++ pkg/config/command_line.go | 11 + pkg/config/config.go | 5 + pkg/fsutils/fsutils.go | 10 + pkg/fsutils/path_resolver.go | 182 ++++++++++++++++ pkg/fsutils/path_resolver_test.go | 181 ++++++++++++++++ pkg/golinters/errcheck_test.go | 60 ++++++ pkg/golinters/gofmt.go | 115 ++++++++++ pkg/golinters/gofmt_test.go | 50 +++++ pkg/golinters/golint_test.go | 15 ++ pkg/golinters/govet_test.go | 22 ++ pkg/golinters/linter.go | 199 ++++++++++++++++++ pkg/golinters/supported_linters.go | 21 ++ pkg/golinters/test.go | 39 ++++ pkg/golinters/utils.go | 68 ++++++ pkg/linter.go | 13 ++ pkg/linter_mock.go | 61 ++++++ pkg/result/issue.go | 19 ++ pkg/result/processors/diff_processor.go | 92 ++++++++ pkg/result/processors/exclude_processor.go | 44 ++++ .../processors/max_per_file_processor.go | 57 +++++ pkg/result/processors/processor.go | 8 + .../processors/uniq_by_line_processor.go | 38 ++++ pkg/result/processors/utils.go | 21 ++ pkg/result/result.go | 6 + pkg/runner.go | 68 ++++++ 29 files changed, 1649 insertions(+) create mode 100644 .gitignore create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 cmd/golangci-lint/main.go create mode 100644 pkg/config/command_line.go create mode 100644 pkg/config/config.go create mode 100644 pkg/fsutils/fsutils.go create mode 100644 pkg/fsutils/path_resolver.go create mode 100644 pkg/fsutils/path_resolver_test.go create mode 100644 pkg/golinters/errcheck_test.go create mode 100644 pkg/golinters/gofmt.go create mode 100644 pkg/golinters/gofmt_test.go create mode 100644 pkg/golinters/golint_test.go create mode 100644 pkg/golinters/govet_test.go create mode 100644 pkg/golinters/linter.go create mode 100644 pkg/golinters/supported_linters.go create mode 100644 pkg/golinters/test.go create mode 100644 pkg/golinters/utils.go create mode 100644 pkg/linter.go create mode 100644 pkg/linter_mock.go create mode 100644 pkg/result/issue.go create mode 100644 pkg/result/processors/diff_processor.go create mode 100644 pkg/result/processors/exclude_processor.go create mode 100644 pkg/result/processors/max_per_file_processor.go create mode 100644 pkg/result/processors/processor.go create mode 100644 pkg/result/processors/uniq_by_line_processor.go create mode 100644 pkg/result/processors/utils.go create mode 100644 pkg/result/result.go create mode 100644 pkg/runner.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..57872d0f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 00000000..dbb0427c --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,150 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/StackExchange/wmi" + packages = ["."] + revision = "5d049714c4a64225c3c79a7cf7d02f7fb5b96338" + version = "1.0.0" + +[[projects]] + name = "github.com/bradleyfalzon/revgrep" + packages = ["."] + revision = "c04006dc3307c8768bda7a33c7c15d1c6f664e14" + version = "v0.3" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + branch = "master" + name = "github.com/dukex/mixpanel" + packages = ["."] + revision = "88b7bfd34643dce0c28a6b797652d6b5026091af" + +[[projects]] + name = "github.com/go-ole/go-ole" + packages = [ + ".", + "oleutil" + ] + revision = "a41e3c4b706f6ae8dfbff342b06e40fa4d2d0506" + version = "v1.2.1" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = ["proto"] + revision = "1adfc126b41513cc696b209667c8656ea7aac67c" + version = "v1.0.0" + +[[projects]] + name = "github.com/golang/mock" + packages = ["gomock"] + revision = "c34cdb4725f4c3844d095133c6e40e448b86589b" + version = "v1.1.1" + +[[projects]] + branch = "master" + name = "github.com/golangci/golangci-shared" + packages = [ + "pkg/analytics", + "pkg/executors", + "pkg/runmode", + "pkg/timeutils" + ] + revision = "044f3332f2e8c38cfbb56bab29f65b4245bbe76b" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + branch = "master" + name = "github.com/savaki/amplitude-go" + packages = ["."] + revision = "f62e3b57c0e4d24da1fad27aa5181c59b0f7868b" + +[[projects]] + name = "github.com/shirou/gopsutil" + packages = [ + "cpu", + "host", + "internal/common", + "mem", + "net", + "process" + ] + revision = "c95755e4bcd7a62bb8bd33f3a597a7c7f35e2cf3" + version = "v2.18.04" + +[[projects]] + branch = "master" + name = "github.com/shirou/w32" + packages = ["."] + revision = "bb4de0191aa41b5507caa14b0650cdbddcd9280b" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" + +[[projects]] + name = "github.com/stretchr/testify" + packages = ["assert"] + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" + +[[projects]] + name = "github.com/stvp/rollbar" + packages = ["."] + revision = "b20261800d8cda3be14dcef0d1a8320779bba61a" + version = "v0.5.1" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "4ec37c66abab2c7e02ae775328b2ff001c3f025a" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "context/ctxhttp" + ] + revision = "640f4622ab692b87c2f3a94265e6f579fe38263d" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "6f686a352de66814cdd080d970febae7767857a3" + +[[projects]] + branch = "master" + name = "sourcegraph.com/sourcegraph/go-diff" + packages = ["diff"] + revision = "3f415a150aec0685cb81b73cc201e762e075006d" + +[[projects]] + branch = "master" + name = "sourcegraph.com/sqs/pbtypes" + packages = ["."] + revision = "4d1b9dc7ffc3f7b555de9b02055fa616f0ebcd18" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "fc3abded9121a32e3fc9de887b9f1dd7a4d64ee58e169053e9120881d6c54658" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 00000000..f6464788 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,50 @@ +# Gopkg.toml example +# +# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[[constraint]] + name = "github.com/bradleyfalzon/revgrep" + version = "0.3.0" + +[[constraint]] + name = "github.com/golang/mock" + version = "1.1.1" + +[[constraint]] + branch = "master" + name = "github.com/golangci/golangci-shared" + +[[constraint]] + name = "github.com/stretchr/testify" + version = "1.2.1" + +[[constraint]] + branch = "master" + name = "sourcegraph.com/sourcegraph/go-diff" + +[prune] + go-tests = true + unused-packages = true diff --git a/cmd/golangci-lint/main.go b/cmd/golangci-lint/main.go new file mode 100644 index 00000000..d04721e7 --- /dev/null +++ b/cmd/golangci-lint/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "log" + "os" + "path/filepath" + + "github.com/golangci/golangci-lint/pkg/config" + "github.com/golangci/golangci-lint/pkg/golinters" + "github.com/golangci/golangci-shared/pkg/executors" +) + +func main() { + if err := run(); err != nil { + panic(err) + } +} + +func run() error { + var cfg config.Config + config.ReadFromCommandLine(&cfg) + + linters := golinters.GetSupportedLinters() + ctx := context.Background() + + ex, err := os.Executable() + if err != nil { + return err + } + exPath := filepath.Dir(ex) + exec := executors.NewShell(exPath) + + for _, linter := range linters { + res, err := linter.Run(ctx, exec) + if err != nil { + return err + } + log.Print(res) + } + + return nil +} diff --git a/pkg/config/command_line.go b/pkg/config/command_line.go new file mode 100644 index 00000000..a99e47b5 --- /dev/null +++ b/pkg/config/command_line.go @@ -0,0 +1,11 @@ +package config + +import "flag" + +func ReadFromCommandLine(cfg *Config) { + flag.Parse() + paths := flag.Args() + if len(paths) != 0 { + cfg.Paths = paths + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 00000000..90579090 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,5 @@ +package config + +type Config struct { + Paths []string +} diff --git a/pkg/fsutils/fsutils.go b/pkg/fsutils/fsutils.go new file mode 100644 index 00000000..1b5a76c3 --- /dev/null +++ b/pkg/fsutils/fsutils.go @@ -0,0 +1,10 @@ +package fsutils + +import ( + "go/build" + "path" +) + +func GetProjectRoot() string { + return path.Join(build.Default.GOPATH, "src", "github.com", "golangci", "golangci-worker") +} diff --git a/pkg/fsutils/path_resolver.go b/pkg/fsutils/path_resolver.go new file mode 100644 index 00000000..ba110508 --- /dev/null +++ b/pkg/fsutils/path_resolver.go @@ -0,0 +1,182 @@ +package fsutils + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +type PathResolver struct { + excludeDirs map[string]bool + allowedFileExtensions map[string]bool +} + +type pathResolveState struct { + files map[string]bool + dirs map[string]bool +} + +func (s *pathResolveState) addFile(path string) { + s.files[filepath.Clean(path)] = true +} + +func (s *pathResolveState) addDir(path string) { + s.dirs[filepath.Clean(path)] = true +} + +type PathResolveResult struct { + files []string + dirs []string +} + +func (prr PathResolveResult) Files() []string { + return prr.files +} + +func (prr PathResolveResult) Dirs() []string { + return prr.dirs +} + +func (s pathResolveState) toResult() *PathResolveResult { + res := &PathResolveResult{ + files: []string{}, + dirs: []string{}, + } + for f := range s.files { + res.files = append(res.files, f) + } + for d := range s.dirs { + res.dirs = append(res.dirs, d) + } + + sort.Strings(res.files) + sort.Strings(res.dirs) + return res +} + +func NewPathResolver(excludeDirs, allowedFileExtensions []string) *PathResolver { + excludeDirsMap := map[string]bool{} + for _, dir := range excludeDirs { + excludeDirsMap[dir] = true + } + + allowedFileExtensionsMap := map[string]bool{} + for _, fe := range allowedFileExtensions { + allowedFileExtensionsMap[fe] = true + } + + return &PathResolver{ + excludeDirs: excludeDirsMap, + allowedFileExtensions: allowedFileExtensionsMap, + } +} + +func (pr PathResolver) isIgnoredDir(dir string) bool { + dirName := filepath.Base(filepath.Clean(dir)) // ignore dirs on any depth level + + // https://github.com/golang/dep/issues/298 + // https://github.com/tools/godep/issues/140 + if strings.HasPrefix(dirName, ".") && dirName != "." { + return true + } + if strings.HasPrefix(dirName, "_") { + return true + } + + return pr.excludeDirs[dirName] +} + +func (pr PathResolver) isAllowedFile(path string) bool { + return pr.allowedFileExtensions[filepath.Ext(path)] +} + +func (pr PathResolver) resolveRecursively(root string, state *pathResolveState) error { + walkErr := filepath.Walk(root, func(p string, i os.FileInfo, err error) error { + if err != nil { + return err + } + + if i.IsDir() { + if pr.isIgnoredDir(p) { + return filepath.SkipDir + } + state.addDir(p) + return nil + } + + if pr.isAllowedFile(p) { + state.addFile(p) + } + return nil + }) + + if walkErr != nil { + return fmt.Errorf("can't walk dir %s: %s", root, walkErr) + } + + return nil +} + +func (pr PathResolver) resolveDir(root string, state *pathResolveState) error { + walkErr := filepath.Walk(root, func(p string, i os.FileInfo, err error) error { + if err != nil { + return err + } + + if i.IsDir() { + if filepath.Clean(p) != filepath.Clean(root) { + return filepath.SkipDir + } + state.addDir(p) + return nil + } + + if pr.isAllowedFile(p) { + state.addFile(p) + } + return nil + }) + + if walkErr != nil { + return fmt.Errorf("can't walk dir %s: %s", root, walkErr) + } + + return nil +} + +func (pr PathResolver) Resolve(paths ...string) (*PathResolveResult, error) { + if len(paths) == 0 { + return nil, fmt.Errorf("no paths are set") + } + + state := &pathResolveState{ + files: map[string]bool{}, + dirs: map[string]bool{}, + } + for _, path := range paths { + if strings.HasSuffix(path, "/...") { + if err := pr.resolveRecursively(filepath.Dir(path), state); err != nil { + return nil, fmt.Errorf("can't recursively resolve %s: %s", path, err) + } + continue + } + + fi, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("can't find path %s: %s", path, err) + } + + if fi.IsDir() { + if err := pr.resolveDir(path, state); err != nil { + return nil, fmt.Errorf("can't resolve dir %s: %s", path, err) + } + continue + } + + state.addFile(path) + } + + return state.toResult(), nil +} diff --git a/pkg/fsutils/path_resolver_test.go b/pkg/fsutils/path_resolver_test.go new file mode 100644 index 00000000..35b5364d --- /dev/null +++ b/pkg/fsutils/path_resolver_test.go @@ -0,0 +1,181 @@ +package fsutils + +import ( + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +type fsPreparer struct { + t *testing.T + root string + prevWD string +} + +func (fp fsPreparer) clean() { + err := os.Chdir(fp.prevWD) + assert.NoError(fp.t, err) + + err = os.RemoveAll(fp.root) + assert.NoError(fp.t, err) +} + +func prepareFS(t *testing.T, paths ...string) *fsPreparer { + root, err := ioutil.TempDir("/tmp", "golangci.test.path_resolver") + assert.NoError(t, err) + + prevWD, err := os.Getwd() + assert.NoError(t, err) + + err = os.Chdir(root) + assert.NoError(t, err) + + for _, p := range paths { + err = os.MkdirAll(filepath.Dir(p), os.ModePerm) + assert.NoError(t, err) + + if strings.HasSuffix(p, "/") { + continue + } + + err = ioutil.WriteFile(p, []byte("test"), os.ModePerm) + assert.NoError(t, err) + } + + return &fsPreparer{ + root: root, + t: t, + prevWD: prevWD, + } +} + +func newPR() *PathResolver { + return NewPathResolver([]string{}, []string{}) +} + +func TestPathResolverNoPaths(t *testing.T) { + _, err := newPR().Resolve() + assert.EqualError(t, err, "no paths are set") +} + +func TestPathResolverNotExistingPath(t *testing.T) { + fp := prepareFS(t) + defer fp.clean() + + _, err := newPR().Resolve("a") + assert.EqualError(t, err, "can't find path a: stat a: no such file or directory") +} + +func TestPathResolverCommonCases(t *testing.T) { + type testCase struct { + name string + prepare []string + resolve []string + expFiles []string + expDirs []string + } + + testCases := []testCase{ + { + name: "empty root recursively", + resolve: []string{"./..."}, + expDirs: []string{"."}, + }, + { + name: "empty root", + resolve: []string{"./"}, + expDirs: []string{"."}, + }, + { + name: "vendor is excluded recursively", + prepare: []string{"vendor/a/"}, + resolve: []string{"./..."}, + expDirs: []string{"."}, + }, + { + name: "vendor is excluded", + prepare: []string{"vendor/"}, + resolve: []string{"./..."}, + expDirs: []string{"."}, + }, + { + name: "vendor implicitely resolved", + prepare: []string{"vendor/"}, + resolve: []string{"./vendor"}, + expDirs: []string{"vendor"}, + }, + { + name: "extensions filter recursively", + prepare: []string{"a/b.go", "a/c.txt", "d.go", "e.csv"}, + resolve: []string{"./..."}, + expDirs: []string{".", "a"}, + expFiles: []string{"a/b.go", "d.go"}, + }, + { + name: "extensions filter", + prepare: []string{"a/b.go", "a/c.txt", "d.go"}, + resolve: []string{"a"}, + expDirs: []string{"a"}, + expFiles: []string{"a/b.go"}, + }, + { + name: "one level dirs exclusion", + prepare: []string{"a/b/", "a/c.go"}, + resolve: []string{"./a"}, + expDirs: []string{"a"}, + expFiles: []string{"a/c.go"}, + }, + { + name: "implicitely resolved files", + prepare: []string{"a/b/c.go", "a/d.txt"}, + resolve: []string{"./a/...", "a/d.txt"}, + expDirs: []string{"a", "a/b"}, + expFiles: []string{"a/b/c.go", "a/d.txt"}, + }, + { + name: ".* is always ignored", + prepare: []string{".git/a.go", ".circleci/b.go"}, + resolve: []string{"./..."}, + expDirs: []string{"."}, + }, + { + name: "exclude dirs on any depth level", + prepare: []string{"ok/.git/a.go"}, + resolve: []string{"./..."}, + expDirs: []string{".", "ok"}, + }, + { + name: "ignore _*", + prepare: []string{"_any/a.go"}, + resolve: []string{"./..."}, + expDirs: []string{"."}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fp := prepareFS(t, tc.prepare...) + defer fp.clean() + + pr := NewPathResolver([]string{"vendor"}, []string{".go"}) + res, err := pr.Resolve(tc.resolve...) + assert.NoError(t, err) + + if tc.expFiles == nil { + assert.Empty(t, res.files) + } else { + assert.Equal(t, tc.expFiles, res.files) + } + + if tc.expDirs == nil { + assert.Empty(t, res.dirs) + } else { + assert.Equal(t, tc.expDirs, res.dirs) + } + }) + } +} diff --git a/pkg/golinters/errcheck_test.go b/pkg/golinters/errcheck_test.go new file mode 100644 index 00000000..5afa444d --- /dev/null +++ b/pkg/golinters/errcheck_test.go @@ -0,0 +1,60 @@ +package golinters + +import ( + "testing" + + "github.com/golangci/golangci-lint/pkg/result" +) + +func TestErrcheckSimple(t *testing.T) { + const source = `package p + + func retErr() error { + return nil + } + + func missedErrorCheck() { + retErr() + } +` + + ExpectIssues(t, errCheck, source, []result.Issue{NewIssue("errcheck", "Error return value is not checked", 8)}) +} + +func TestErrcheckIgnoreClose(t *testing.T) { + sources := []string{`package p + + import "os" + + func ok() error { + f, err := os.Open("t.go") + if err != nil { + return err + } + + f.Close() + return nil + } +`, + `package p + +import "net/http" + +func f() { + resp, err := http.Get("http://example.com/") + if err != nil { + panic(err) + } + defer resp.Body.Close() + + panic(resp) +} +`} + + for _, source := range sources { + ExpectIssues(t, errCheck, source, []result.Issue{}) + } +} + +// TODO: add cases of non-compiling code +// TODO: don't report issues if got more than 20 issues diff --git a/pkg/golinters/gofmt.go b/pkg/golinters/gofmt.go new file mode 100644 index 00000000..283974a1 --- /dev/null +++ b/pkg/golinters/gofmt.go @@ -0,0 +1,115 @@ +package golinters + +import ( + "bytes" + "context" + "fmt" + + "github.com/golangci/golangci-lint/pkg/result" + "github.com/golangci/golangci-shared/pkg/analytics" + "github.com/golangci/golangci-shared/pkg/executors" + "sourcegraph.com/sourcegraph/go-diff/diff" +) + +type gofmt struct { + useGoimports bool +} + +func (g gofmt) Name() string { + if g.useGoimports { + return "goimports" + } + + return "gofmt" +} + +func getFirstDeletedLineNumberInHunk(h *diff.Hunk) (int, error) { + lines := bytes.Split(h.Body, []byte{'\n'}) + lineNumber := int(h.OrigStartLine - 1) + for _, line := range lines { + lineNumber++ + + if len(line) == 0 { + continue + } + if line[0] == '-' { + return lineNumber, nil + } + } + + return 0, fmt.Errorf("didn't find deletion line in hunk %s", string(h.Body)) +} + +func (g gofmt) extractIssuesFromPatch(patch string) ([]result.Issue, error) { + diffs, err := diff.ParseMultiFileDiff([]byte(patch)) + if err != nil { + return nil, fmt.Errorf("can't parse patch: %s", err) + } + + if len(diffs) == 0 { + return nil, fmt.Errorf("got no diffs from patch parser: %v", diffs) + } + + issues := []result.Issue{} + for _, d := range diffs { + if len(d.Hunks) == 0 { + analytics.Log(context.TODO()).Warnf("Got no hunks in diff %+v", d) + continue + } + + for _, hunk := range d.Hunks { + lineNumber, err := getFirstDeletedLineNumberInHunk(hunk) + if err != nil { + analytics.Log(context.TODO()).Infof("Can't get first deleted line number for hunk: %s", err) + lineNumber = int(hunk.OrigStartLine) // use first line if no deletions: + } + + text := "File is not gofmt-ed with -s" + if g.useGoimports { + text = "File is not goimports-ed" + } + i := result.Issue{ + FromLinter: g.Name(), + File: d.NewName, + LineNumber: lineNumber, + Text: text, + } + issues = append(issues, i) + } + } + + return issues, nil +} + +func (g gofmt) Run(ctx context.Context, exec executors.Executor) (*result.Result, error) { + paths, err := getPathsForGoProject(exec.WorkDir()) + if err != nil { + return nil, fmt.Errorf("can't get files to analyze: %s", err) + } + + args := []string{"-d"} + if !g.useGoimports { + args = append(args, "-s") + } + args = append(args, paths.files...) + out, err := exec.Run(ctx, g.Name(), args...) + if err != nil { + return nil, fmt.Errorf("can't run gofmt: %s, %s", err, out) + } + + if len(out) == 0 { // no diff => no issues + return &result.Result{ + Issues: []result.Issue{}, + }, nil + } + + issues, err := g.extractIssuesFromPatch(out) + if err != nil { + return nil, fmt.Errorf("can't extract issues from gofmt diff output %q: %s", out, err) + } + + return &result.Result{ + Issues: issues, + MaxIssuesPerFile: 1, // don't disturb user: show just first changed not gofmt-ed line + }, nil +} diff --git a/pkg/golinters/gofmt_test.go b/pkg/golinters/gofmt_test.go new file mode 100644 index 00000000..06131dcc --- /dev/null +++ b/pkg/golinters/gofmt_test.go @@ -0,0 +1,50 @@ +package golinters + +import ( + "testing" + + "github.com/golangci/golangci-lint/pkg/result" +) + +func TestGofmtIssueFound(t *testing.T) { + const source = `package p + +func noFmt() error { +return nil +} +` + + ExpectIssues(t, gofmt{}, source, []result.Issue{NewIssue("gofmt", "File is not gofmt-ed with -s", 4)}) +} + +func TestGofmtNoIssue(t *testing.T) { + const source = `package p + +func fmted() error { + return nil +} +` + + ExpectIssues(t, gofmt{}, source, []result.Issue{}) +} + +func TestGoimportsIssueFound(t *testing.T) { + const source = `package p +func noFmt() error {return nil} +` + + lint := gofmt{useGoimports: true} + ExpectIssues(t, lint, source, []result.Issue{NewIssue("goimports", "File is not goimports-ed", 2)}) +} + +func TestGoimportsNoIssue(t *testing.T) { + const source = `package p + +func fmted() error { + return nil +} +` + + lint := gofmt{useGoimports: true} + ExpectIssues(t, lint, source, []result.Issue{}) +} diff --git a/pkg/golinters/golint_test.go b/pkg/golinters/golint_test.go new file mode 100644 index 00000000..453ca813 --- /dev/null +++ b/pkg/golinters/golint_test.go @@ -0,0 +1,15 @@ +package golinters + +import ( + "testing" + + "github.com/golangci/golangci-lint/pkg/result" +) + +func TestGolintSimple(t *testing.T) { + const source = `package p + var v_1 string` + + ExpectIssues(t, golint, source, + []result.Issue{NewIssue("golint", "don't use underscores in Go names; var v_1 should be v1", 2)}) +} diff --git a/pkg/golinters/govet_test.go b/pkg/golinters/govet_test.go new file mode 100644 index 00000000..5826e2e1 --- /dev/null +++ b/pkg/golinters/govet_test.go @@ -0,0 +1,22 @@ +package golinters + +import ( + "testing" + + "github.com/golangci/golangci-lint/pkg/result" +) + +func TestGovetSimple(t *testing.T) { + const source = `package p + +import "os" + +func f() error { + return &os.PathError{"first", "path", os.ErrNotExist} +} +` + + ExpectIssues(t, govet, source, []result.Issue{ + NewIssue("govet", "os.PathError composite literal uses unkeyed fields", 6), + }) +} diff --git a/pkg/golinters/linter.go b/pkg/golinters/linter.go new file mode 100644 index 00000000..ee49faa5 --- /dev/null +++ b/pkg/golinters/linter.go @@ -0,0 +1,199 @@ +package golinters + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "syscall" + "text/template" + + "github.com/golangci/golangci-lint/pkg/result" + "github.com/golangci/golangci-shared/pkg/analytics" + "github.com/golangci/golangci-shared/pkg/executors" +) + +type linterConfig struct { + messageTemplate *template.Template + pattern *regexp.Regexp + excludeByMessagePattern *regexp.Regexp + args []string + issuesFoundExitCode int +} + +func newLinterConfig(messageTemplate, pattern, excludeByMessagePattern string, args ...string) *linterConfig { + if messageTemplate == "" { + messageTemplate = "{{.message}}" + } + + var excludeByMessagePatternRe *regexp.Regexp + if excludeByMessagePattern != "" { + excludeByMessagePatternRe = regexp.MustCompile(excludeByMessagePattern) + } + + return &linterConfig{ + messageTemplate: template.Must(template.New("message").Parse(messageTemplate)), + pattern: regexp.MustCompile(pattern), + excludeByMessagePattern: excludeByMessagePatternRe, + args: args, + issuesFoundExitCode: 1, + } +} + +type linter struct { + name string + + linterConfig +} + +func newLinter(name string, cfg *linterConfig) *linter { + return &linter{ + name: name, + linterConfig: *cfg, + } +} + +func (lint linter) Name() string { + return lint.name +} + +func (lint linter) doesExitCodeMeansIssuesWereFound(err error) bool { + ee, ok := err.(*exec.ExitError) + if !ok { + return false + } + + status, ok := ee.Sys().(syscall.WaitStatus) + if !ok { + return false + } + + exitCode := status.ExitStatus() + return exitCode == lint.issuesFoundExitCode +} + +func getOutTail(out string, linesCount int) string { + lines := strings.Split(out, "\n") + if len(lines) <= linesCount { + return out + } + + return strings.Join(lines[len(lines)-linesCount:], "\n") +} + +func (lint linter) Run(ctx context.Context, exec executors.Executor) (*result.Result, error) { + paths, err := getPathsForGoProject(exec.WorkDir()) + if err != nil { + return nil, fmt.Errorf("can't get files to analyze: %s", err) + } + + retIssues := []result.Issue{} + + const maxDirsPerRun = 100 // run one linter multiple times with groups of dirs: limit memory usage in the cost of higher CPU usage + + for len(paths.dirs) != 0 { + args := append([]string{}, lint.args...) + + dirsCount := len(paths.dirs) + if dirsCount > maxDirsPerRun { + dirsCount = maxDirsPerRun + } + dirs := paths.dirs[:dirsCount] + args = append(args, dirs...) + + out, err := exec.Run(ctx, lint.name, args...) + if err != nil && !lint.doesExitCodeMeansIssuesWereFound(err) { + out = getOutTail(out, 10) + return nil, fmt.Errorf("can't run linter %s with args %v: %s, %s", lint.name, lint.args, err, out) + } + + issues := lint.parseLinterOut(out) + retIssues = append(retIssues, issues...) + + paths.dirs = paths.dirs[dirsCount:] + } + + return &result.Result{ + Issues: retIssues, + }, nil +} + +type regexpVars map[string]string + +func buildMatchedRegexpVars(match []string, pattern *regexp.Regexp) regexpVars { + result := regexpVars{} + for i, name := range pattern.SubexpNames() { + if i != 0 && name != "" { + result[name] = match[i] + } + } + return result +} + +func (lint linter) parseLinterOutLine(line string) (regexpVars, error) { + match := lint.pattern.FindStringSubmatch(line) + if match == nil { + return nil, fmt.Errorf("can't match line %q against regexp", line) + } + + return buildMatchedRegexpVars(match, lint.pattern), nil +} + +func (lint linter) makeIssue(vars regexpVars) (*result.Issue, error) { + var messageBuffer bytes.Buffer + err := lint.messageTemplate.Execute(&messageBuffer, vars) + if err != nil { + return nil, fmt.Errorf("can't execute message template: %s", err) + } + + if vars["path"] == "" { + return nil, fmt.Errorf("no path in vars %+v", vars) + } + + var line int + if vars["line"] != "" { + line, err = strconv.Atoi(vars["line"]) + if err != nil { + analytics.Log(context.TODO()).Warnf("Can't parse line %q: %s", vars["line"], err) + } + } + + return &result.Issue{ + FromLinter: lint.name, + File: vars["path"], + LineNumber: line, + Text: messageBuffer.String(), + }, nil +} + +func (lint linter) parseLinterOut(out string) []result.Issue { + issues := []result.Issue{} + scanner := bufio.NewScanner(strings.NewReader(out)) + for scanner.Scan() { + vars, err := lint.parseLinterOutLine(scanner.Text()) + if err != nil { + analytics.Log(context.TODO()).Warnf("Can't parse linter out line: %s", err) + continue + } + + message := vars["message"] + ex := lint.excludeByMessagePattern + if message != "" && ex != nil && ex.MatchString(message) { + continue + } + + issue, err := lint.makeIssue(vars) + if err != nil { + analytics.Log(context.TODO()).Warnf("Can't make issue: %s", err) + continue + } + + issues = append(issues, *issue) + } + + return issues +} diff --git a/pkg/golinters/supported_linters.go b/pkg/golinters/supported_linters.go new file mode 100644 index 00000000..2429b530 --- /dev/null +++ b/pkg/golinters/supported_linters.go @@ -0,0 +1,21 @@ +package golinters + +import "github.com/golangci/golangci-lint/pkg" + +const pathLineColMessage = `^(?P.*?\.go):(?P\d+):(?P\d+):\s*(?P.*)$` +const pathLineMessage = `^(?P.*?\.go):(?P\d+):\s*(?P.*)$` + +var errCheck = newLinter("errcheck", + newLinterConfig( + "Error return value is not checked", + pathLineColMessage, + "\\.Close()", // It's annoying and not critical error to ignore Close() errors), + ), +) + +var golint = newLinter("golint", newLinterConfig("", pathLineColMessage, "")) +var govet = newLinter("govet", newLinterConfig("", pathLineMessage, "", "--no-recurse")) + +func GetSupportedLinters() []linters.Linter { + return []linters.Linter{gofmt{}, gofmt{useGoimports: true}, golint, govet} +} diff --git a/pkg/golinters/test.go b/pkg/golinters/test.go new file mode 100644 index 00000000..4c8358be --- /dev/null +++ b/pkg/golinters/test.go @@ -0,0 +1,39 @@ +package golinters + +import ( + "context" + "io/ioutil" + "os" + "path" + "testing" + + "github.com/golangci/golangci-lint/pkg" + "github.com/golangci/golangci-lint/pkg/result" + "github.com/golangci/golangci-shared/pkg/executors" + "github.com/stretchr/testify/assert" +) + +func NewIssue(linter, message string, line int) result.Issue { + return result.Issue{ + FromLinter: linter, + Text: message, + File: "p/f.go", + LineNumber: line, + } +} + +func ExpectIssues(t *testing.T, linter linters.Linter, source string, issues []result.Issue) { + exec, err := executors.NewTempDirShell("test.expectissues") + assert.NoError(t, err) + defer exec.Clean() + + subDir := path.Join(exec.WorkDir(), "p") + assert.NoError(t, os.Mkdir(subDir, os.ModePerm)) + err = ioutil.WriteFile(path.Join(subDir, "f.go"), []byte(source), os.ModePerm) + assert.NoError(t, err) + + res, err := linter.Run(context.Background(), exec) + assert.NoError(t, err) + + assert.Equal(t, issues, res.Issues) +} diff --git a/pkg/golinters/utils.go b/pkg/golinters/utils.go new file mode 100644 index 00000000..c44947d5 --- /dev/null +++ b/pkg/golinters/utils.go @@ -0,0 +1,68 @@ +package golinters + +import ( + "context" + "fmt" + "log" + "path" + "path/filepath" + + "github.com/golangci/golangci-lint/pkg/fsutils" + "github.com/golangci/golangci-shared/pkg/analytics" +) + +type ProjectPaths struct { + files []string + dirs []string +} + +func processPaths(root string, paths []string, maxPaths int) ([]string, error) { + if len(paths) >= maxPaths { + analytics.Log(context.TODO()).Warnf("Gofmt: got too much paths (%d), analyze first %d", len(paths), maxPaths) + paths = paths[:maxPaths] + } + + ret := []string{} + for i := range paths { + relPath, err := filepath.Rel(root, paths[i]) + if err != nil { + return nil, fmt.Errorf("can't get relative path for path %s and root %s: %s", + paths[i], root, err) + } + ret = append(ret, relPath) + } + + return ret, nil +} + +func getPathsForGoProject(root string) (*ProjectPaths, error) { + excludeDirs := []string{"vendor", "testdata", "examples", "Godeps"} + pr := fsutils.NewPathResolver(excludeDirs, []string{".go"}) + log.Printf("root is %q, paths are %q", root, path.Join(root, "...")) + paths, err := pr.Resolve(path.Join(root, "...")) + if err != nil { + return nil, fmt.Errorf("can't resolve paths: %s", err) + } + + files, err := processPaths(root, paths.Files(), 10000) + if err != nil { + return nil, fmt.Errorf("can't process resolved files: %s", err) + } + + dirs, err := processPaths(root, paths.Dirs(), 1000) + if err != nil { + return nil, fmt.Errorf("can't process resolved dirs: %s", err) + } + + for i := range dirs { + dir := dirs[i] + if dir != "." { + dirs[i] = "./" + dir + } + } + + return &ProjectPaths{ + files: files, + dirs: dirs, + }, nil +} diff --git a/pkg/linter.go b/pkg/linter.go new file mode 100644 index 00000000..44a0c0b4 --- /dev/null +++ b/pkg/linter.go @@ -0,0 +1,13 @@ +package linters + +import ( + "context" + + "github.com/golangci/golangci-lint/pkg/result" + "github.com/golangci/golangci-shared/pkg/executors" +) + +type Linter interface { + Run(ctx context.Context, exec executors.Executor) (*result.Result, error) + Name() string +} diff --git a/pkg/linter_mock.go b/pkg/linter_mock.go new file mode 100644 index 00000000..9d651d40 --- /dev/null +++ b/pkg/linter_mock.go @@ -0,0 +1,61 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./app/analyze/linters/linter.go + +package linters + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + result "github.com/golangci/golangci-lint/pkg/result" + executors "github.com/golangci/golangci-shared/pkg/executors" +) + +// MockLinter is a mock of Linter interface +type MockLinter struct { + ctrl *gomock.Controller + recorder *MockLinterMockRecorder +} + +// MockLinterMockRecorder is the mock recorder for MockLinter +type MockLinterMockRecorder struct { + mock *MockLinter +} + +// NewMockLinter creates a new mock instance +func NewMockLinter(ctrl *gomock.Controller) *MockLinter { + mock := &MockLinter{ctrl: ctrl} + mock.recorder = &MockLinterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (_m *MockLinter) EXPECT() *MockLinterMockRecorder { + return _m.recorder +} + +// Run mocks base method +func (_m *MockLinter) Run(ctx context.Context, exec executors.Executor) (*result.Result, error) { + ret := _m.ctrl.Call(_m, "Run", ctx, exec) + ret0, _ := ret[0].(*result.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Run indicates an expected call of Run +func (_mr *MockLinterMockRecorder) Run(arg0, arg1 interface{}) *gomock.Call { + return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Run", reflect.TypeOf((*MockLinter)(nil).Run), arg0, arg1) +} + +// Name mocks base method +func (_m *MockLinter) Name() string { + ret := _m.ctrl.Call(_m, "Name") + ret0, _ := ret[0].(string) + return ret0 +} + +// Name indicates an expected call of Name +func (_mr *MockLinterMockRecorder) Name() *gomock.Call { + return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Name", reflect.TypeOf((*MockLinter)(nil).Name)) +} diff --git a/pkg/result/issue.go b/pkg/result/issue.go new file mode 100644 index 00000000..56d73d27 --- /dev/null +++ b/pkg/result/issue.go @@ -0,0 +1,19 @@ +package result + +type Issue struct { + FromLinter string + Text string + File string + LineNumber int + HunkPos int +} + +func NewIssue(fromLinter, text, file string, lineNumber, hunkPos int) Issue { + return Issue{ + FromLinter: fromLinter, + Text: text, + File: file, + LineNumber: lineNumber, + HunkPos: hunkPos, + } +} diff --git a/pkg/result/processors/diff_processor.go b/pkg/result/processors/diff_processor.go new file mode 100644 index 00000000..ebfb548c --- /dev/null +++ b/pkg/result/processors/diff_processor.go @@ -0,0 +1,92 @@ +package processors + +import ( + "fmt" + "io/ioutil" + "strings" + + "github.com/bradleyfalzon/revgrep" + "github.com/golangci/golangci-lint/pkg/result" +) + +type DiffProcessor struct { + patch string +} + +func NewDiffProcessor(patch string) *DiffProcessor { + return &DiffProcessor{ + patch: patch, + } +} + +func (p DiffProcessor) Name() string { + return "diff" +} + +func (p DiffProcessor) processResult(res result.Result) (*result.Result, error) { + // Make mapping to restore original issues metadata later + fli := makeFilesToLinesToIssuesMap([]result.Result{res}) + + rIssues, err := p.runRevgrepOnIssues(res.Issues) + if err != nil { + return nil, err + } + + newIssues := []result.Issue{} + for _, ri := range rIssues { + if fli[ri.File] == nil { + return nil, fmt.Errorf("can't get original issue file for %v", ri) + } + + oi := fli[ri.File][ri.LineNo] + if len(oi) != 1 { + return nil, fmt.Errorf("can't get original issue for %v: %v", ri, oi) + } + + i := result.Issue{ + File: ri.File, + LineNumber: ri.LineNo, + Text: ri.Message, + HunkPos: ri.HunkPos, + FromLinter: oi[0].FromLinter, + } + newIssues = append(newIssues, i) + } + + res.Issues = newIssues + return &res, nil +} + +func (p DiffProcessor) Process(results []result.Result) ([]result.Result, error) { + retResults := []result.Result{} + for _, res := range results { + newRes, err := p.processResult(res) + if err != nil { + return nil, fmt.Errorf("can't filter only new issues for result %+v: %s", res, err) + } + retResults = append(retResults, *newRes) + } + + return retResults, nil +} + +func (p DiffProcessor) runRevgrepOnIssues(issues []result.Issue) ([]revgrep.Issue, error) { + // TODO: change revgrep to accept interface with line number, file name + fakeIssuesLines := []string{} + for _, i := range issues { + line := fmt.Sprintf("%s:%d:%d: %s", i.File, i.LineNumber, 0, i.Text) + fakeIssuesLines = append(fakeIssuesLines, line) + } + fakeIssuesOut := strings.Join(fakeIssuesLines, "\n") + + checker := revgrep.Checker{ + Patch: strings.NewReader(p.patch), + Regexp: `^([^:]+):(\d+):(\d+)?:?\s*(.*)$`, + } + rIssues, err := checker.Check(strings.NewReader(fakeIssuesOut), ioutil.Discard) + if err != nil { + return nil, fmt.Errorf("can't filter only new issues by revgrep: %s", err) + } + + return rIssues, nil +} diff --git a/pkg/result/processors/exclude_processor.go b/pkg/result/processors/exclude_processor.go new file mode 100644 index 00000000..459276ed --- /dev/null +++ b/pkg/result/processors/exclude_processor.go @@ -0,0 +1,44 @@ +package processors + +import ( + "regexp" + + "github.com/golangci/golangci-lint/pkg/result" +) + +type ExcludeProcessor struct { + pattern *regexp.Regexp +} + +var _ Processor = ExcludeProcessor{} + +func NewExcludeProcessor(pattern string) *ExcludeProcessor { + return &ExcludeProcessor{ + pattern: regexp.MustCompile(pattern), + } +} + +func (p ExcludeProcessor) Name() string { + return "exclude" +} + +func (p ExcludeProcessor) processResult(res result.Result) result.Result { + newRes := res + newRes.Issues = []result.Issue{} + for _, i := range res.Issues { + if !p.pattern.MatchString(i.Text) { + newRes.Issues = append(newRes.Issues, i) + } + } + + return newRes +} + +func (p ExcludeProcessor) Process(results []result.Result) ([]result.Result, error) { + retResults := []result.Result{} + for _, res := range results { + retResults = append(retResults, p.processResult(res)) + } + + return retResults, nil +} diff --git a/pkg/result/processors/max_per_file_processor.go b/pkg/result/processors/max_per_file_processor.go new file mode 100644 index 00000000..b5c5221c --- /dev/null +++ b/pkg/result/processors/max_per_file_processor.go @@ -0,0 +1,57 @@ +package processors + +import "github.com/golangci/golangci-lint/pkg/result" + +type MaxLinterIssuesPerFile struct{} + +var _ Processor = MaxLinterIssuesPerFile{} + +type fileToIssuesMap map[string][]result.Issue + +func (p MaxLinterIssuesPerFile) Name() string { + return "max_issues_per_file" +} + +func (p MaxLinterIssuesPerFile) makeFileToIssuesMap(res result.Result) fileToIssuesMap { + fti := fileToIssuesMap{} + for _, i := range res.Issues { + fti[i.File] = append(fti[i.File], i) + } + + return fti +} + +func (p MaxLinterIssuesPerFile) processResult(res result.Result) result.Result { + if len(res.Issues) == 0 { + return res + } + + if res.MaxIssuesPerFile == 0 { + return res // Nothing to process + } + + fti := p.makeFileToIssuesMap(res) + for file, fileIssues := range fti { + if len(fileIssues) > res.MaxIssuesPerFile { + fti[file] = fileIssues[:res.MaxIssuesPerFile] + } + } + + filteredIssues := []result.Issue{} + for _, issues := range fti { + filteredIssues = append(filteredIssues, issues...) + } + + res.Issues = filteredIssues + return res +} + +func (p MaxLinterIssuesPerFile) Process(results []result.Result) ([]result.Result, error) { + newResults := []result.Result{} + + for _, res := range results { + newResults = append(newResults, p.processResult(res)) + } + + return newResults, nil +} diff --git a/pkg/result/processors/processor.go b/pkg/result/processors/processor.go new file mode 100644 index 00000000..018acb07 --- /dev/null +++ b/pkg/result/processors/processor.go @@ -0,0 +1,8 @@ +package processors + +import "github.com/golangci/golangci-lint/pkg/result" + +type Processor interface { + Process(results []result.Result) ([]result.Result, error) + Name() string +} diff --git a/pkg/result/processors/uniq_by_line_processor.go b/pkg/result/processors/uniq_by_line_processor.go new file mode 100644 index 00000000..183ae734 --- /dev/null +++ b/pkg/result/processors/uniq_by_line_processor.go @@ -0,0 +1,38 @@ +package processors + +import ( + "fmt" + + "github.com/golangci/golangci-lint/pkg/result" +) + +type UniqByLineProcessor struct{} + +var _ Processor = UniqByLineProcessor{} + +func (p UniqByLineProcessor) Name() string { + return "uniq_by_line" +} + +func (p UniqByLineProcessor) Process(results []result.Result) ([]result.Result, error) { + fli := makeFilesToLinesToIssuesMap(results) + + retResults := []result.Result{} + for _, res := range results { + newRes := res + newRes.Issues = []result.Issue{} + for _, i := range res.Issues { + lineIssues := fli[i.File][i.LineNumber] + if len(lineIssues) == 0 { + return nil, fmt.Errorf("bug in by line uniqalization") + } + + if lineIssues[0] == i { // Use first issue for line + newRes.Issues = append(newRes.Issues, i) + } + } + retResults = append(retResults, newRes) + } + + return retResults, nil +} diff --git a/pkg/result/processors/utils.go b/pkg/result/processors/utils.go new file mode 100644 index 00000000..57f54ceb --- /dev/null +++ b/pkg/result/processors/utils.go @@ -0,0 +1,21 @@ +package processors + +import "github.com/golangci/golangci-lint/pkg/result" + +type linesToIssuesMap map[int][]result.Issue +type filesToLinesToIssuesMap map[string]linesToIssuesMap + +func makeFilesToLinesToIssuesMap(results []result.Result) filesToLinesToIssuesMap { + fli := filesToLinesToIssuesMap{} + for _, res := range results { + for _, i := range res.Issues { + if fli[i.File] == nil { + fli[i.File] = linesToIssuesMap{} + } + li := fli[i.File] + li[i.LineNumber] = append(li[i.LineNumber], i) + } + } + + return fli +} diff --git a/pkg/result/result.go b/pkg/result/result.go new file mode 100644 index 00000000..e9e8571a --- /dev/null +++ b/pkg/result/result.go @@ -0,0 +1,6 @@ +package result + +type Result struct { + Issues []Issue + MaxIssuesPerFile int // Needed for gofmt and goimports where it is 1 +} diff --git a/pkg/runner.go b/pkg/runner.go new file mode 100644 index 00000000..4ef7f865 --- /dev/null +++ b/pkg/runner.go @@ -0,0 +1,68 @@ +package linters + +import ( + "context" + "fmt" + + "github.com/golangci/golangci-lint/pkg/result" + "github.com/golangci/golangci-lint/pkg/result/processors" + "github.com/golangci/golangci-shared/pkg/analytics" + "github.com/golangci/golangci-shared/pkg/executors" +) + +type Runner interface { + Run(ctx context.Context, linters []Linter, exec executors.Executor) ([]result.Issue, error) +} + +type SimpleRunner struct { + Processors []processors.Processor +} + +func (r SimpleRunner) Run(ctx context.Context, linters []Linter, exec executors.Executor) ([]result.Issue, error) { + results := []result.Result{} + for _, linter := range linters { + res, err := linter.Run(ctx, exec) + if err != nil { + analytics.Log(ctx).Warnf("Can't run linter %+v: %s", linter, err) + continue + } + + if len(res.Issues) == 0 { + continue + } + + results = append(results, *res) + } + + results, err := r.processResults(results) + if err != nil { + return nil, fmt.Errorf("can't process results: %s", err) + } + + return r.mergeResults(results), nil +} + +func (r SimpleRunner) processResults(results []result.Result) ([]result.Result, error) { + if len(r.Processors) == 0 { + return results, nil + } + + for _, p := range r.Processors { + var err error + results, err = p.Process(results) + if err != nil { + return nil, err + } + } + + return results, nil +} + +func (r SimpleRunner) mergeResults(results []result.Result) []result.Issue { + issues := []result.Issue{} + for _, r := range results { + issues = append(issues, r.Issues...) + } + + return issues +}