feat: new output.formats file configuration syntax (#4521)

This commit is contained in:
Ludovic Fernandez 2024-03-18 13:27:04 +01:00 committed by GitHub
parent b91c194126
commit e3ed3ba1d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 258 additions and 51 deletions

View File

@ -59,15 +59,25 @@ run:
# output configuration options # output configuration options
output: output:
# Format: colored-line-number|line-number|json|colored-tab|tab|checkstyle|code-climate|junit-xml|github-actions|teamcity # The formats used to render issues.
# # Format: `colored-line-number`, `line-number`, `json`, `colored-tab`, `tab`, `checkstyle`, `code-climate`, `junit-xml`, `github-actions`, `teamcity`
# Multiple can be specified by separating them by comma, output can be provided
# for each of them by separating format name and path by colon symbol.
# Output path can be either `stdout`, `stderr` or path to the file to write to. # Output path can be either `stdout`, `stderr` or path to the file to write to.
# Example: "checkstyle:report.xml,json:stdout,colored-line-number"
# #
# Default: colored-line-number # For the CLI flag (`--out-format`), multiple formats can be specified by separating them by comma.
format: json # The output can be specified for each of them by separating format name and path by colon symbol.
# Example: "--out-format=checkstyle:report.xml,json:stdout,colored-line-number"
# The CLI flag (`--out-format`) override the configuration file.
#
# Default:
# formats:
# - format: colored-line-number
# path: stdout
formats:
- format: json
path: stderr
- format: checkstyle
path: report.xml
- format: colored-line-number
# Print lines of code with issue. # Print lines of code with issue.
# Default: true # Default: true

View File

@ -451,15 +451,42 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"format": { "formats": {
"description": "Output format to use.", "description": "Output formats to use.",
"pattern": "^(,?(colored-line-number|line-number|json|colored-tab|tab|checkstyle|code-climate|junit-xml|github-actions|teamcity)(:[^,]+)?)+$", "type": "array",
"default": "colored-line-number", "items": {
"examples": [ "type": "object",
"colored-line-number", "additionalProperties": false,
"checkstyle:report.json,colored-line-number", "properties": {
"line-number:golangci-lint.out,colored-line-number:stdout" "path": {
] "default": "stdout",
"anyOf": [
{
"enum": [ "stdout", "stderr" ]
},
{
"type": "string"
}
]
},
"format": {
"default": "colored-line-number",
"enum": [
"colored-line-number",
"line-number",
"json",
"colored-tab",
"tab",
"checkstyle",
"code-climate",
"junit-xml",
"github-actions",
"teamcity"
]
}
},
"required": ["format"]
}
}, },
"print-issued-lines": { "print-issued-lines": {
"description": "Print lines of code with issue.", "description": "Print lines of code with issue.",

View File

@ -63,8 +63,8 @@ func setupRunFlagSet(v *viper.Viper, fs *pflag.FlagSet) {
} }
func setupOutputFlagSet(v *viper.Viper, fs *pflag.FlagSet) { func setupOutputFlagSet(v *viper.Viper, fs *pflag.FlagSet) {
internal.AddFlagAndBind(v, fs, fs.String, "out-format", "output.format", config.OutFormatColoredLineNumber, internal.AddFlagAndBind(v, fs, fs.String, "out-format", "output.formats", config.OutFormatColoredLineNumber,
color.GreenString(fmt.Sprintf("Format of output: %s", strings.Join(config.OutFormats, "|")))) color.GreenString(fmt.Sprintf("Formats of output: %s", strings.Join(config.AllOutputFormats, "|"))))
internal.AddFlagAndBind(v, fs, fs.Bool, "print-issued-lines", "output.print-issued-lines", true, internal.AddFlagAndBind(v, fs, fs.Bool, "print-issued-lines", "output.print-issued-lines", true,
color.GreenString("Print lines of code with issue")) color.GreenString("Print lines of code with issue"))
internal.AddFlagAndBind(v, fs, fs.Bool, "print-linter-name", "output.print-linter-name", true, internal.AddFlagAndBind(v, fs, fs.Bool, "print-linter-name", "output.print-linter-name", true,

View File

@ -61,7 +61,10 @@ func (l *Loader) Load() error {
l.handleGoVersion() l.handleGoVersion()
l.handleDeprecation() err = l.handleDeprecation()
if err != nil {
return err
}
err = l.handleEnableOnlyOption() err = l.handleEnableOnlyOption()
if err != nil { if err != nil {
@ -164,7 +167,7 @@ func (l *Loader) parseConfig() error {
var configFileNotFoundError viper.ConfigFileNotFoundError var configFileNotFoundError viper.ConfigFileNotFoundError
if errors.As(err, &configFileNotFoundError) { if errors.As(err, &configFileNotFoundError) {
// Load configuration from flags only. // Load configuration from flags only.
err = l.viper.Unmarshal(l.cfg) err = l.viper.Unmarshal(l.cfg, customDecoderHook())
if err != nil { if err != nil {
return fmt.Errorf("can't unmarshal config by viper (flags): %w", err) return fmt.Errorf("can't unmarshal config by viper (flags): %w", err)
} }
@ -181,7 +184,7 @@ func (l *Loader) parseConfig() error {
} }
// Load configuration from all sources (flags, file). // Load configuration from all sources (flags, file).
if err := l.viper.Unmarshal(l.cfg, fileDecoderHook()); err != nil { if err := l.viper.Unmarshal(l.cfg, customDecoderHook()); err != nil {
return fmt.Errorf("can't unmarshal config by viper (flags, file): %w", err) return fmt.Errorf("can't unmarshal config by viper (flags, file): %w", err)
} }
@ -279,28 +282,47 @@ func (l *Loader) handleGoVersion() {
} }
} }
func (l *Loader) handleDeprecation() { func (l *Loader) handleDeprecation() error {
// Deprecated since v1.57.0
if len(l.cfg.Run.SkipFiles) > 0 { if len(l.cfg.Run.SkipFiles) > 0 {
l.warn("The configuration option `run.skip-files` is deprecated, please use `issues.exclude-files`.") l.warn("The configuration option `run.skip-files` is deprecated, please use `issues.exclude-files`.")
l.cfg.Issues.ExcludeFiles = l.cfg.Run.SkipFiles l.cfg.Issues.ExcludeFiles = l.cfg.Run.SkipFiles
} }
// Deprecated since v1.57.0
if len(l.cfg.Run.SkipDirs) > 0 { if len(l.cfg.Run.SkipDirs) > 0 {
l.warn("The configuration option `run.skip-dirs` is deprecated, please use `issues.exclude-dirs`.") l.warn("The configuration option `run.skip-dirs` is deprecated, please use `issues.exclude-dirs`.")
l.cfg.Issues.ExcludeDirs = l.cfg.Run.SkipDirs l.cfg.Issues.ExcludeDirs = l.cfg.Run.SkipDirs
} }
// The 2 options are true by default. // The 2 options are true by default.
// Deprecated since v1.57.0
if !l.cfg.Run.UseDefaultSkipDirs { if !l.cfg.Run.UseDefaultSkipDirs {
l.warn("The configuration option `run.skip-dirs-use-default` is deprecated, please use `issues.exclude-dirs-use-default`.") l.warn("The configuration option `run.skip-dirs-use-default` is deprecated, please use `issues.exclude-dirs-use-default`.")
} }
l.cfg.Issues.UseDefaultExcludeDirs = l.cfg.Run.UseDefaultSkipDirs && l.cfg.Issues.UseDefaultExcludeDirs l.cfg.Issues.UseDefaultExcludeDirs = l.cfg.Run.UseDefaultSkipDirs && l.cfg.Issues.UseDefaultExcludeDirs
// The 2 options are false by default. // The 2 options are false by default.
// Deprecated since v1.57.0
if l.cfg.Run.ShowStats { if l.cfg.Run.ShowStats {
l.warn("The configuration option `run.show-stats` is deprecated, please use `output.show-stats`") l.warn("The configuration option `run.show-stats` is deprecated, please use `output.show-stats`")
} }
l.cfg.Output.ShowStats = l.cfg.Run.ShowStats || l.cfg.Output.ShowStats l.cfg.Output.ShowStats = l.cfg.Run.ShowStats || l.cfg.Output.ShowStats
// Deprecated since v1.57.0
if l.cfg.Output.Format != "" {
l.warn("The configuration option `output.format` is deprecated, please use `output.formats`")
var f OutputFormats
err := f.UnmarshalText([]byte(l.cfg.Output.Format))
if err != nil {
return fmt.Errorf("unmarshal output format: %w", err)
}
l.cfg.Output.Formats = f
}
return nil
} }
func (l *Loader) handleEnableOnlyOption() error { func (l *Loader) handleEnableOnlyOption() error {
@ -332,13 +354,13 @@ func (l *Loader) warn(format string) {
l.log.Warnf(format) l.log.Warnf(format)
} }
func fileDecoderHook() viper.DecoderConfigOption { func customDecoderHook() viper.DecoderConfigOption {
return viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( return viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
// Default hooks (https://github.com/spf13/viper/blob/518241257478c557633ab36e474dfcaeb9a3c623/viper.go#L135-L138). // Default hooks (https://github.com/spf13/viper/blob/518241257478c557633ab36e474dfcaeb9a3c623/viper.go#L135-L138).
mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","), mapstructure.StringToSliceHookFunc(","),
// Needed for forbidigo. // Needed for forbidigo, and output.formats.
mapstructure.TextUnmarshallerHookFunc(), mapstructure.TextUnmarshallerHookFunc(),
)) ))
} }

View File

@ -21,7 +21,7 @@ const (
OutFormatTeamCity = "teamcity" OutFormatTeamCity = "teamcity"
) )
var OutFormats = []string{ var AllOutputFormats = []string{
OutFormatColoredLineNumber, OutFormatColoredLineNumber,
OutFormatLineNumber, OutFormatLineNumber,
OutFormatJSON, OutFormatJSON,
@ -35,14 +35,17 @@ var OutFormats = []string{
} }
type Output struct { type Output struct {
Format string `mapstructure:"format"` Formats OutputFormats `mapstructure:"formats"`
PrintIssuedLine bool `mapstructure:"print-issued-lines"` PrintIssuedLine bool `mapstructure:"print-issued-lines"`
PrintLinterName bool `mapstructure:"print-linter-name"` PrintLinterName bool `mapstructure:"print-linter-name"`
UniqByLine bool `mapstructure:"uniq-by-line"` UniqByLine bool `mapstructure:"uniq-by-line"`
SortResults bool `mapstructure:"sort-results"` SortResults bool `mapstructure:"sort-results"`
SortOrder []string `mapstructure:"sort-order"` SortOrder []string `mapstructure:"sort-order"`
PathPrefix string `mapstructure:"path-prefix"` PathPrefix string `mapstructure:"path-prefix"`
ShowStats bool `mapstructure:"show-stats"` ShowStats bool `mapstructure:"show-stats"`
// Deprecated: use Formats instead.
Format string `mapstructure:"format"`
} }
func (o *Output) Validate() error { func (o *Output) Validate() error {
@ -64,5 +67,46 @@ func (o *Output) Validate() error {
} }
} }
for _, format := range o.Formats {
err := format.Validate()
if err != nil {
return err
}
}
return nil
}
type OutputFormat struct {
Format string `mapstructure:"format"`
Path string `mapstructure:"path"`
}
func (o *OutputFormat) Validate() error {
if o.Format == "" {
return errors.New("the format is required")
}
if !slices.Contains(AllOutputFormats, o.Format) {
return fmt.Errorf("unsupported output format %q", o.Format)
}
return nil
}
type OutputFormats []OutputFormat
func (p *OutputFormats) UnmarshalText(text []byte) error {
formats := strings.Split(string(text), ",")
for _, item := range formats {
format, path, _ := strings.Cut(item, ":")
*p = append(*p, OutputFormat{
Path: path,
Format: format,
})
}
return nil return nil
} }

View File

@ -81,6 +81,86 @@ func TestOutput_Validate_error(t *testing.T) {
}, },
expected: `the sort-order name "linter" is repeated several times`, expected: `the sort-order name "linter" is repeated several times`,
}, },
{
desc: "unsupported format",
settings: &Output{
Formats: []OutputFormat{
{
Format: "test",
},
},
},
expected: `unsupported output format "test"`,
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := test.settings.Validate()
require.EqualError(t, err, test.expected)
})
}
}
func TestOutputFormat_Validate(t *testing.T) {
testCases := []struct {
desc string
settings *OutputFormat
}{
{
desc: "only format",
settings: &OutputFormat{
Format: "json",
},
},
{
desc: "format and path (relative)",
settings: &OutputFormat{
Format: "json",
Path: "./example.json",
},
},
{
desc: "format and path (absolute)",
settings: &OutputFormat{
Format: "json",
Path: "/tmp/example.json",
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := test.settings.Validate()
require.NoError(t, err)
})
}
}
func TestOutputFormat_Validate_error(t *testing.T) {
testCases := []struct {
desc string
settings *OutputFormat
expected string
}{
{
desc: "empty",
settings: &OutputFormat{},
expected: "the format is required",
},
{
desc: "unsupported format",
settings: &OutputFormat{
Format: "test",
},
expected: `unsupported output format "test"`,
},
} }
for _, test := range testCases { for _, test := range testCases {

View File

@ -6,7 +6,6 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/golangci/golangci-lint/pkg/config" "github.com/golangci/golangci-lint/pkg/config"
"github.com/golangci/golangci-lint/pkg/logutils" "github.com/golangci/golangci-lint/pkg/logutils"
@ -54,11 +53,8 @@ func NewPrinter(log logutils.Log, cfg *config.Output, reportData *report.Data) (
// Print prints issues based on the formats defined // Print prints issues based on the formats defined
func (c *Printer) Print(issues []result.Issue) error { func (c *Printer) Print(issues []result.Issue) error {
formats := strings.Split(c.cfg.Format, ",") for _, format := range c.cfg.Formats {
err := c.printReports(issues, format)
for _, item := range formats {
format, path, _ := strings.Cut(item, ":")
err := c.printReports(issues, path, format)
if err != nil { if err != nil {
return err return err
} }
@ -67,10 +63,10 @@ func (c *Printer) Print(issues []result.Issue) error {
return nil return nil
} }
func (c *Printer) printReports(issues []result.Issue, path, format string) error { func (c *Printer) printReports(issues []result.Issue, format config.OutputFormat) error {
w, shouldClose, err := c.createWriter(path) w, shouldClose, err := c.createWriter(format.Path)
if err != nil { if err != nil {
return fmt.Errorf("can't create output for %s: %w", path, err) return fmt.Errorf("can't create output for %s: %w", format.Path, err)
} }
defer func() { defer func() {
@ -79,7 +75,7 @@ func (c *Printer) printReports(issues []result.Issue, path, format string) error
} }
}() }()
p, err := c.createPrinter(format, w) p, err := c.createPrinter(format.Format, w)
if err != nil { if err != nil {
return err return err
} }
@ -140,7 +136,7 @@ func (c *Printer) createPrinter(format string, w io.Writer) (issuePrinter, error
case config.OutFormatTeamCity: case config.OutFormatTeamCity:
p = NewTeamCity(w) p = NewTeamCity(w)
default: default:
return nil, fmt.Errorf("unknown output format %s", format) return nil, fmt.Errorf("unknown output format %q", format)
} }
return p, nil return p, nil

View File

@ -43,14 +43,21 @@ func TestPrinter_Print_stdout(t *testing.T) {
{ {
desc: "stdout (implicit)", desc: "stdout (implicit)",
cfg: &config.Output{ cfg: &config.Output{
Format: "line-number", Formats: []config.OutputFormat{
{Format: "line-number"},
},
}, },
expected: "golden-line-number.txt", expected: "golden-line-number.txt",
}, },
{ {
desc: "stdout (explicit)", desc: "stdout (explicit)",
cfg: &config.Output{ cfg: &config.Output{
Format: "line-number:stdout", Formats: []config.OutputFormat{
{
Format: "line-number",
Path: "stdout",
},
},
}, },
expected: "golden-line-number.txt", expected: "golden-line-number.txt",
}, },
@ -92,7 +99,12 @@ func TestPrinter_Print_stderr(t *testing.T) {
unmarshalFile(t, "in-report-data.json", data) unmarshalFile(t, "in-report-data.json", data)
cfg := &config.Output{ cfg := &config.Output{
Format: "line-number:stderr", Formats: []config.OutputFormat{
{
Format: "line-number",
Path: "stderr",
},
},
} }
p, err := NewPrinter(logger, cfg, data) p, err := NewPrinter(logger, cfg, data)
@ -126,7 +138,12 @@ func TestPrinter_Print_file(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "report.txt") outputPath := filepath.Join(t.TempDir(), "report.txt")
cfg := &config.Output{ cfg := &config.Output{
Format: "line-number:" + outputPath, Formats: []config.OutputFormat{
{
Format: "line-number",
Path: outputPath,
},
},
} }
p, err := NewPrinter(logger, cfg, data) p, err := NewPrinter(logger, cfg, data)
@ -165,9 +182,20 @@ func TestPrinter_Print_multiple(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "github-actions.txt") outputPath := filepath.Join(t.TempDir(), "github-actions.txt")
cfg := &config.Output{ cfg := &config.Output{
Format: "github-actions:" + outputPath + Formats: []config.OutputFormat{
",json" + {
",line-number:stderr", Format: "github-actions",
Path: outputPath,
},
{
Format: "json",
Path: "",
},
{
Format: "line-number",
Path: "stderr",
},
},
} }
p, err := NewPrinter(logger, cfg, data) p, err := NewPrinter(logger, cfg, data)