diff --git a/lintcmd/cmd.go b/lintcmd/cmd.go index d5ced2496..8df92f8c0 100644 --- a/lintcmd/cmd.go +++ b/lintcmd/cmd.go @@ -29,6 +29,12 @@ import ( "golang.org/x/tools/go/buildutil" ) +type BuildConfig struct { + Name string + Envs []string + Flags []string +} + // Command represents a linter command line tool. type Command struct { name string @@ -39,15 +45,19 @@ type Command struct { flags struct { fs *flag.FlagSet - tags string - tests bool - printVersion bool - showIgnored bool - formatter string + tags string + tests bool + showIgnored bool + formatter string + + // mutually exclusive mode flags explain string + printVersion bool listChecks bool merge bool + matrix bool + debugCpuprofile string debugMemprofile string debugVersion bool @@ -135,6 +145,7 @@ func (cmd *Command) initFlagSet(name string) { flags.StringVar(&cmd.flags.explain, "explain", "", "Print description of `check`") flags.BoolVar(&cmd.flags.listChecks, "list-checks", false, "List all available checks") flags.BoolVar(&cmd.flags.merge, "merge", false, "Merge results of multiple Staticcheck runs") + flags.BoolVar(&cmd.flags.matrix, "matrix", false, "Read a build config matrix from stdin") flags.StringVar(&cmd.flags.debugCpuprofile, "debug.cpuprofile", "", "Write CPU profile to `file`") flags.StringVar(&cmd.flags.debugMemprofile, "debug.memprofile", "", "Write memory profile to `file`") @@ -201,17 +212,43 @@ func (cmd *Command) ParseFlags(args []string) { cmd.flags.fs.Parse(args) } +// diagnosticDescriptor represents the uniquiely identifying information of diagnostics. type diagnosticDescriptor struct { Position token.Position End token.Position Category string Message string } + +func (diag diagnostic) descriptor() diagnosticDescriptor { + return diagnosticDescriptor{ + Position: diag.Position, + End: diag.End, + Category: diag.Category, + Message: diag.Message, + } +} + type run struct { checkedFiles map[string]struct{} diagnostics map[diagnosticDescriptor]diagnostic } +func runFromLintResult(res LintResult) run { + out := run{ + checkedFiles: map[string]struct{}{}, + diagnostics: map[diagnosticDescriptor]diagnostic{}, + } + + for _, cf := range res.CheckedFiles { + out.checkedFiles[cf] = struct{}{} + } + for _, diag := range res.Diagnostics { + out.diagnostics[diag.descriptor()] = diag + } + return out +} + func decodeGob(br io.ByteReader) ([]run, error) { var runs []run for { @@ -223,26 +260,7 @@ func decodeGob(br io.ByteReader) ([]run, error) { return nil, err } } - - theRun := run{ - checkedFiles: map[string]struct{}{}, - diagnostics: map[diagnosticDescriptor]diagnostic{}, - } - - for _, cf := range bin.CheckedFiles { - theRun.checkedFiles[cf] = struct{}{} - } - for _, diag := range bin.Diagnostics { - desc := diagnosticDescriptor{ - Position: diag.Position, - End: diag.End, - Category: diag.Category, - Message: diag.Message, - } - theRun.diagnostics[desc] = diag - } - - runs = append(runs, theRun) + runs = append(runs, runFromLintResult(res)) } return runs, nil } @@ -357,15 +375,6 @@ func (cmd *Command) Run() { relevantDiagnostics := mergeRuns(runs) cmd.printDiagnostics(cs, relevantDiagnostics) default: - // Validate that the tags argument is well-formed. go/packages - // doesn't detect malformed build flags and returns unhelpful - // errors. - tf := buildutil.TagsFlag{} - if err := tf.Set(cmd.flags.tags); err != nil { - fmt.Fprintln(os.Stderr, fmt.Errorf("invalid value %q for flag -tags: %s", cmd.flags.tags, err)) - cmd.exit(1) - } - switch cmd.flags.formatter { case "text", "stylish", "json", "sarif", "binary", "null": default: @@ -373,33 +382,74 @@ func (cmd *Command) Run() { cmd.exit(2) } - res, err := doLint(cs, cmd.flags.fs.Args(), &options{ - Tags: cmd.flags.tags, - LintTests: cmd.flags.tests, - GoVersion: string(cmd.flags.goVersion), - Config: config.Config{ - Checks: cmd.flags.checks, - }, - PrintAnalyzerMeasurement: measureAnalyzers, - }) - if err != nil { - fmt.Fprintln(os.Stderr, err) - cmd.exit(1) - } + var bconfs []BuildConfig + if cmd.flags.matrix { + if cmd.flags.tags != "" { + fmt.Fprintln(os.Stderr, "cannot use -matrix and -tags together") + cmd.exit(2) + } - for _, w := range res.Warnings { - fmt.Fprintln(os.Stderr, "warning:", w) + var err error + bconfs, err = parseBuildConfigs(os.Stdin) + if err != nil { + if err, ok := err.(parseBuildConfigError); ok { + fmt.Fprintf(os.Stderr, ":%d:%d: couldn't parse build matrix: %s\n", err.line+1, err.offset+1, err.msg) + } else { + fmt.Fprintln(os.Stderr, err) + } + os.Exit(2) + } + } else { + bc := BuildConfig{} + if cmd.flags.tags != "" { + // Validate that the tags argument is well-formed. go/packages + // doesn't detect malformed build flags and returns unhelpful + // errors. + tf := buildutil.TagsFlag{} + if err := tf.Set(cmd.flags.tags); err != nil { + fmt.Fprintln(os.Stderr, fmt.Errorf("invalid value %q for flag -tags: %s", cmd.flags.tags, err)) + cmd.exit(1) + } + + bc.Flags = []string{"-tags", cmd.flags.tags} + } + bconfs = append(bconfs, bc) } - if cmd.flags.formatter == "binary" { - err := gob.NewEncoder(os.Stdout).Encode(res) + var runs []run + for _, bconf := range bconfs { + res, err := doLint(cs, cmd.flags.fs.Args(), &options{ + BuildConfig: bconf, + LintTests: cmd.flags.tests, + GoVersion: string(cmd.flags.goVersion), + Config: config.Config{ + Checks: cmd.flags.checks, + }, + PrintAnalyzerMeasurement: measureAnalyzers, + }) if err != nil { - fmt.Fprintf(os.Stderr, "failed writing output: %s\n", err) - cmd.exit(2) + fmt.Fprintln(os.Stderr, err) + cmd.exit(1) } - cmd.exit(0) - } else { - cmd.printDiagnostics(cs, res.Diagnostics) + + for _, w := range res.Warnings { + fmt.Fprintln(os.Stderr, "warning:", w) + } + + if cmd.flags.formatter == "binary" { + err := gob.NewEncoder(os.Stdout).Encode(res) + if err != nil { + fmt.Fprintf(os.Stderr, "failed writing output: %s\n", err) + cmd.exit(2) + } + } else { + runs = append(runs, runFromLintResult(res)) + } + } + + if cmd.flags.formatter != "binary" { + diags := mergeRuns(runs) + cmd.printDiagnostics(cs, diags) } } } @@ -415,13 +465,7 @@ func mergeRuns(runs []run) []diagnostic { doPrint := true for _, r := range runs { if _, ok := r.checkedFiles[diag.Position.Filename]; ok { - desc := diagnosticDescriptor{ - Position: diag.Position, - End: diag.End, - Category: diag.Category, - Message: diag.Message, - } - if _, ok := r.diagnostics[desc]; !ok { + if _, ok := r.diagnostics[diag.descriptor()]; !ok { doPrint = false } } @@ -456,8 +500,10 @@ func (cmd *Command) exit(code int) { func (cmd *Command) printDiagnostics(cs []*lint.Analyzer, diagnostics []diagnostic) { if len(diagnostics) > 1 { sort.Slice(diagnostics, func(i, j int) bool { - pi := diagnostics[i].Position - pj := diagnostics[j].Position + di := diagnostics[i] + dj := diagnostics[j] + pi := di.Position + pj := dj.Position if pi.Filename != pj.Filename { return pi.Filename < pj.Filename @@ -468,20 +514,46 @@ func (cmd *Command) printDiagnostics(cs []*lint.Analyzer, diagnostics []diagnost if pi.Column != pj.Column { return pi.Column < pj.Column } - - return diagnostics[i].Message < diagnostics[j].Message + if di.Message != dj.Message { + return di.Message < dj.Message + } + if di.BuildName != dj.BuildName { + return di.BuildName < dj.BuildName + } + return di.Category < dj.Category }) - var filtered []diagnostic - filtered = append(filtered, diagnostics[0]) - for i, diag := range diagnostics[1:] { + filtered := []diagnostic{ + diagnostics[0], + } + builds := []map[string]struct{}{ + {diagnostics[0].BuildName: {}}, + } + for _, diag := range diagnostics[1:] { // We may encounter duplicate diagnostics because one file - // can be part of many packages. - if !diagnostics[i].equal(diag) { - filtered = append(filtered, diag) + // can be part of many packages, and because multiple + // build configurations may check the same files. + if !filtered[len(filtered)-1].equal(diag) { + if filtered[len(filtered)-1].descriptor() == diag.descriptor() { + // Diagnostics only differ in build name, track new name + builds[len(filtered)-1][diag.BuildName] = struct{}{} + } else { + filtered = append(filtered, diag) + builds = append(builds, map[string]struct{}{}) + builds[len(filtered)-1][diag.BuildName] = struct{}{} + } } } + var names []string + for i := range filtered { + names = names[:0] + for k := range builds[i] { + names = append(names, k) + } + sort.Strings(names) + filtered[i].BuildName = strings.Join(names, ",") + } diagnostics = filtered } diff --git a/lintcmd/config.go b/lintcmd/config.go new file mode 100644 index 000000000..500a2f872 --- /dev/null +++ b/lintcmd/config.go @@ -0,0 +1,223 @@ +package lintcmd + +import ( + "bufio" + "errors" + "fmt" + "io" + "strings" + "unicode" +) + +func parseBuildConfigs(r io.Reader) ([]BuildConfig, error) { + var builds []BuildConfig + br := bufio.NewReader(r) + i := 0 + for { + line, err := br.ReadString('\n') + if err != nil { + if err == io.EOF { + break + } else { + return nil, err + } + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + name, envs, flags, err := parseBuildConfig(line) + if err != nil { + if err, ok := err.(parseBuildConfigError); ok { + err.line = i + return nil, err + } else { + return nil, err + } + } + + bc := BuildConfig{ + Name: name, + Envs: make([]string, 0, len(envs)), + Flags: make([]string, 0, len(flags)), + } + for _, env := range envs { + bc.Envs = append(bc.Envs, fmt.Sprintf("%s=%s", env[0], env[1])) + } + for _, flag := range flags { + bc.Flags = append(bc.Flags, flag[0], flag[1]) + } + builds = append(builds, bc) + + i++ + } + return builds, nil +} + +type parseBuildConfigError struct { + line int // 0-based line number + offset int // 0-based offset + msg string +} + +func (err parseBuildConfigError) Error() string { return err.msg } + +func parseBuildConfig(line string) (name string, envs, flags [][2]string, err error) { + if line == "" { + return "", nil, nil, errors.New("couldn't parse empty build config") + } + if strings.Index(line, ":") == len(line)-1 { + name = line[:len(line)-1] + } else { + idx := strings.Index(line, ": ") + if idx == -1 { + return name, envs, flags, parseBuildConfigError{0, 0, "missing build name"} + } + name = line[:idx] + off := idx + 2 + line = strings.TrimSpace(line[idx+2:]) + + const ( + stateStart = iota + stateEnvName + stateEnvValueStart + stateEnvValueQuoted + stateEnvValue + stateFlagNameStart + stateFlagName + stateFlagValueStart + stateFlagValueQuoted + stateFlagValue + ) + + state := stateStart + start := 0 + var valL string + for i, r := range line { + switch state { + case stateStart: + valL = "" + + if r == '-' { + state = stateFlagNameStart + start = i + } else if r == ' ' { + } else if r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' { + state = stateEnvName + start = i + } else { + return name, envs, flags, parseBuildConfigError{0, i + off, fmt.Sprintf("expected start of environment variable or flag, got %q", r)} + } + case stateEnvName: + if r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' { + } else if r == '=' { + valL = line[start:i] + state = stateEnvValueStart + start = i + 1 + } else { + return name, envs, flags, parseBuildConfigError{0, i + off, fmt.Sprintf("invalid character %q in environment variable name", r)} + } + case stateEnvValueStart: + if r == '"' { + state = stateEnvValueQuoted + start = i + 1 + } else if r != ' ' { + state = stateEnvValue + } else { + // empty value + envs = append(envs, [2]string{valL, ""}) + state = stateStart + } + case stateEnvValueQuoted: + if r == '"' { + // end of value + envs = append(envs, [2]string{valL, line[start:i]}) + state = stateStart + } + case stateEnvValue: + if r == ' ' { + // end of value + envs = append(envs, [2]string{valL, line[start:i]}) + state = stateStart + } + case stateFlagNameStart: + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' || r == '-' { + state = stateFlagName + } else { + return name, envs, flags, parseBuildConfigError{0, i + off, fmt.Sprintf("invalid character %q in flag name", r)} + } + case stateFlagName: + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' || r == '-' { + } else if r == ' ' { + // flag without value + flags = append(flags, [2]string{line[start:i], ""}) + state = stateStart + } else if r == '=' { + // flag with value + valL = line[start:i] + state = stateFlagValueStart + start = i + 1 + } else { + return name, envs, flags, parseBuildConfigError{0, i + off, fmt.Sprintf("invalid character %q in flag name", r)} + } + case stateFlagValueStart: + if r == '"' { + state = stateFlagValueQuoted + start = i + 1 + } else if r != ' ' { + state = stateFlagValue + } else { + // empty value + flags = append(flags, [2]string{valL, ""}) + state = stateStart + } + case stateFlagValueQuoted: + if r == '"' { + // end of value + flags = append(flags, [2]string{valL, line[start:i]}) + state = stateStart + } + case stateFlagValue: + if r == ' ' { + // end of value + flags = append(flags, [2]string{valL, line[start:i]}) + state = stateStart + } + default: + panic(state) + } + } + + switch state { + case stateStart: + // nothing to do + case stateEnvName: + return name, envs, flags, parseBuildConfigError{0, len(line) + off, "unexpected end of line"} + case stateEnvValueStart: + fmt.Println("empty env value") + case stateEnvValueQuoted: + return name, envs, flags, parseBuildConfigError{0, len(line) + off, "unexpected end of line"} + case stateEnvValue: + envs = append(envs, [2]string{valL, line[start:]}) + case stateFlagNameStart: + return name, envs, flags, parseBuildConfigError{0, len(line) + off, "unexpected end of line"} + case stateFlagName: + flags = append(flags, [2]string{line[start:], ""}) + case stateFlagValueStart: + flags = append(flags, [2]string{valL, ""}) + case stateFlagValueQuoted: + return name, envs, flags, parseBuildConfigError{0, len(line) + off, "unexpected end of line"} + case stateFlagValue: + flags = append(flags, [2]string{valL, line[start:]}) + default: + panic(state) + } + } + + for _, r := range name { + if !(r == '_' || unicode.IsLetter(r) || unicode.IsNumber(r)) { + return "", nil, nil, fmt.Errorf("invalid build name %q", name) + } + } + return name, envs, flags, nil +} diff --git a/lintcmd/config_test.go b/lintcmd/config_test.go new file mode 100644 index 000000000..6c1521acb --- /dev/null +++ b/lintcmd/config_test.go @@ -0,0 +1,88 @@ +//go:build go1.18 +// +build go1.18 + +package lintcmd + +import ( + "testing" +) + +var buildConfigTests = []struct { + in string + name string + envs [][2]string + flags [][2]string + invalid bool +}{ + { + `some_name: ENV1=foo ENV_2=bar ENV3="foo bar baz" ENV4=foo"bar -flag1 -flag2= -flag3=value -flag4="some value"`, + "some_name", + [][2]string{{"ENV1", "foo"}, {"ENV_2", "bar"}, {"ENV3", "foo bar baz"}, {"ENV4", `foo"bar`}}, + [][2]string{{"-flag1", ""}, {"-flag2", ""}, {"-flag3", "value"}, {"-flag4", "some value"}}, + false, + }, + { + "some_name:", + "some_name", + nil, + nil, + false, + }, + { + "some name:", + "", + nil, + nil, + true, + }, + { + "", + "", + nil, + nil, + true, + }, +} + +func FuzzParseBuildConfig(f *testing.F) { + equal := func(a, b [][2]string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true + } + + for _, tt := range buildConfigTests { + f.Add(tt.in) + + name, envs, flags, err := parseBuildConfig(tt.in) + if err != nil { + if tt.invalid { + continue + } + f.Fatalf("input %q failed to parse: %s", tt.in, err) + } + if tt.invalid { + f.Fatalf("expected input %q to fail but it didn't", tt.in) + } + + if name != tt.name { + f.Fatalf("got name %q, want %q", name, tt.name) + } + if !equal(envs, tt.envs) { + f.Fatalf("got environment %#v, want %#v", envs, tt.envs) + } + if !equal(flags, tt.flags) { + f.Fatalf("got flags %#v, want %#v", flags, tt.flags) + } + } + + f.Fuzz(func(t *testing.T, in string) { + parseBuildConfig(in) + }) +} diff --git a/lintcmd/lint.go b/lintcmd/lint.go index 9fd184c4f..b754cd2f4 100644 --- a/lintcmd/lint.go +++ b/lintcmd/lint.go @@ -319,8 +319,9 @@ func (s severity) String() string { // diagnostic represents a diagnostic in some source code. type diagnostic struct { runner.Diagnostic - Severity severity - MergeIf lint.MergeStrategy + Severity severity + MergeIf lint.MergeStrategy + BuildName string } func (p diagnostic) equal(o diagnostic) bool { @@ -329,11 +330,16 @@ func (p diagnostic) equal(o diagnostic) bool { p.Message == o.Message && p.Category == o.Category && p.Severity == o.Severity && - p.MergeIf == o.MergeIf + p.MergeIf == o.MergeIf && + p.BuildName == o.BuildName } func (p *diagnostic) String() string { - return fmt.Sprintf("%s (%s)", p.Message, p.Category) + if p.BuildName != "" { + return fmt.Sprintf("%s [%s] (%s)", p.Message, p.BuildName, p.Category) + } else { + return fmt.Sprintf("%s (%s)", p.Message, p.Category) + } } func failed(res runner.Result) []diagnostic { @@ -489,9 +495,9 @@ func parsePos(pos string) (token.Position, int, error) { } type options struct { - Config config.Config + Config config.Config + BuildConfig BuildConfig - Tags string LintTests bool GoVersion string PrintAnalyzerMeasurement func(analysis *analysis.Analyzer, pkg *loader.PackageSpec, d time.Duration) @@ -518,9 +524,9 @@ func doLint(as []*lint.Analyzer, paths []string, opt *options) (LintResult, erro if opt.LintTests { cfg.Tests = true } - if opt.Tags != "" { - cfg.BuildFlags = append(cfg.BuildFlags, "-tags", opt.Tags) - } + + cfg.BuildFlags = opt.BuildConfig.Flags + cfg.Env = append(os.Environ(), opt.BuildConfig.Envs...) printStats := func() { // Individual stats are read atomically, but overall there @@ -556,5 +562,9 @@ func doLint(as []*lint.Analyzer, paths []string, opt *options) (LintResult, erro } }() } - return l.Lint(cfg, paths) + res, err := l.Lint(cfg, paths) + for i := range res.Diagnostics { + res.Diagnostics[i].BuildName = opt.BuildConfig.Name + } + return res, err }