Skip to content

Commit e3ed3ba

Browse files
authored
feat: new output.formats file configuration syntax (#4521)
1 parent b91c194 commit e3ed3ba

File tree

8 files changed

+258
-51
lines changed

8 files changed

+258
-51
lines changed

.golangci.next.reference.yml

+17-7
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,25 @@ run:
5959

6060
# output configuration options
6161
output:
62-
# Format: colored-line-number|line-number|json|colored-tab|tab|checkstyle|code-climate|junit-xml|github-actions|teamcity
63-
#
64-
# Multiple can be specified by separating them by comma, output can be provided
65-
# for each of them by separating format name and path by colon symbol.
62+
# The formats used to render issues.
63+
# Format: `colored-line-number`, `line-number`, `json`, `colored-tab`, `tab`, `checkstyle`, `code-climate`, `junit-xml`, `github-actions`, `teamcity`
6664
# Output path can be either `stdout`, `stderr` or path to the file to write to.
67-
# Example: "checkstyle:report.xml,json:stdout,colored-line-number"
6865
#
69-
# Default: colored-line-number
70-
format: json
66+
# For the CLI flag (`--out-format`), multiple formats can be specified by separating them by comma.
67+
# The output can be specified for each of them by separating format name and path by colon symbol.
68+
# Example: "--out-format=checkstyle:report.xml,json:stdout,colored-line-number"
69+
# The CLI flag (`--out-format`) override the configuration file.
70+
#
71+
# Default:
72+
# formats:
73+
# - format: colored-line-number
74+
# path: stdout
75+
formats:
76+
- format: json
77+
path: stderr
78+
- format: checkstyle
79+
path: report.xml
80+
- format: colored-line-number
7181

7282
# Print lines of code with issue.
7383
# Default: true

jsonschema/golangci.next.jsonschema.json

+36-9
Original file line numberDiff line numberDiff line change
@@ -451,15 +451,42 @@
451451
"type": "object",
452452
"additionalProperties": false,
453453
"properties": {
454-
"format": {
455-
"description": "Output format to use.",
456-
"pattern": "^(,?(colored-line-number|line-number|json|colored-tab|tab|checkstyle|code-climate|junit-xml|github-actions|teamcity)(:[^,]+)?)+$",
457-
"default": "colored-line-number",
458-
"examples": [
459-
"colored-line-number",
460-
"checkstyle:report.json,colored-line-number",
461-
"line-number:golangci-lint.out,colored-line-number:stdout"
462-
]
454+
"formats": {
455+
"description": "Output formats to use.",
456+
"type": "array",
457+
"items": {
458+
"type": "object",
459+
"additionalProperties": false,
460+
"properties": {
461+
"path": {
462+
"default": "stdout",
463+
"anyOf": [
464+
{
465+
"enum": [ "stdout", "stderr" ]
466+
},
467+
{
468+
"type": "string"
469+
}
470+
]
471+
},
472+
"format": {
473+
"default": "colored-line-number",
474+
"enum": [
475+
"colored-line-number",
476+
"line-number",
477+
"json",
478+
"colored-tab",
479+
"tab",
480+
"checkstyle",
481+
"code-climate",
482+
"junit-xml",
483+
"github-actions",
484+
"teamcity"
485+
]
486+
}
487+
},
488+
"required": ["format"]
489+
}
463490
},
464491
"print-issued-lines": {
465492
"description": "Print lines of code with issue.",

pkg/commands/flagsets.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ func setupRunFlagSet(v *viper.Viper, fs *pflag.FlagSet) {
6363
}
6464

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

pkg/config/loader.go

+28-6
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ func (l *Loader) Load() error {
6161

6262
l.handleGoVersion()
6363

64-
l.handleDeprecation()
64+
err = l.handleDeprecation()
65+
if err != nil {
66+
return err
67+
}
6568

6669
err = l.handleEnableOnlyOption()
6770
if err != nil {
@@ -164,7 +167,7 @@ func (l *Loader) parseConfig() error {
164167
var configFileNotFoundError viper.ConfigFileNotFoundError
165168
if errors.As(err, &configFileNotFoundError) {
166169
// Load configuration from flags only.
167-
err = l.viper.Unmarshal(l.cfg)
170+
err = l.viper.Unmarshal(l.cfg, customDecoderHook())
168171
if err != nil {
169172
return fmt.Errorf("can't unmarshal config by viper (flags): %w", err)
170173
}
@@ -181,7 +184,7 @@ func (l *Loader) parseConfig() error {
181184
}
182185

183186
// Load configuration from all sources (flags, file).
184-
if err := l.viper.Unmarshal(l.cfg, fileDecoderHook()); err != nil {
187+
if err := l.viper.Unmarshal(l.cfg, customDecoderHook()); err != nil {
185188
return fmt.Errorf("can't unmarshal config by viper (flags, file): %w", err)
186189
}
187190

@@ -279,28 +282,47 @@ func (l *Loader) handleGoVersion() {
279282
}
280283
}
281284

282-
func (l *Loader) handleDeprecation() {
285+
func (l *Loader) handleDeprecation() error {
286+
// Deprecated since v1.57.0
283287
if len(l.cfg.Run.SkipFiles) > 0 {
284288
l.warn("The configuration option `run.skip-files` is deprecated, please use `issues.exclude-files`.")
285289
l.cfg.Issues.ExcludeFiles = l.cfg.Run.SkipFiles
286290
}
287291

292+
// Deprecated since v1.57.0
288293
if len(l.cfg.Run.SkipDirs) > 0 {
289294
l.warn("The configuration option `run.skip-dirs` is deprecated, please use `issues.exclude-dirs`.")
290295
l.cfg.Issues.ExcludeDirs = l.cfg.Run.SkipDirs
291296
}
292297

293298
// The 2 options are true by default.
299+
// Deprecated since v1.57.0
294300
if !l.cfg.Run.UseDefaultSkipDirs {
295301
l.warn("The configuration option `run.skip-dirs-use-default` is deprecated, please use `issues.exclude-dirs-use-default`.")
296302
}
297303
l.cfg.Issues.UseDefaultExcludeDirs = l.cfg.Run.UseDefaultSkipDirs && l.cfg.Issues.UseDefaultExcludeDirs
298304

299305
// The 2 options are false by default.
306+
// Deprecated since v1.57.0
300307
if l.cfg.Run.ShowStats {
301308
l.warn("The configuration option `run.show-stats` is deprecated, please use `output.show-stats`")
302309
}
303310
l.cfg.Output.ShowStats = l.cfg.Run.ShowStats || l.cfg.Output.ShowStats
311+
312+
// Deprecated since v1.57.0
313+
if l.cfg.Output.Format != "" {
314+
l.warn("The configuration option `output.format` is deprecated, please use `output.formats`")
315+
316+
var f OutputFormats
317+
err := f.UnmarshalText([]byte(l.cfg.Output.Format))
318+
if err != nil {
319+
return fmt.Errorf("unmarshal output format: %w", err)
320+
}
321+
322+
l.cfg.Output.Formats = f
323+
}
324+
325+
return nil
304326
}
305327

306328
func (l *Loader) handleEnableOnlyOption() error {
@@ -332,13 +354,13 @@ func (l *Loader) warn(format string) {
332354
l.log.Warnf(format)
333355
}
334356

335-
func fileDecoderHook() viper.DecoderConfigOption {
357+
func customDecoderHook() viper.DecoderConfigOption {
336358
return viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
337359
// Default hooks (https://github.com/spf13/viper/blob/518241257478c557633ab36e474dfcaeb9a3c623/viper.go#L135-L138).
338360
mapstructure.StringToTimeDurationHookFunc(),
339361
mapstructure.StringToSliceHookFunc(","),
340362

341-
// Needed for forbidigo.
363+
// Needed for forbidigo, and output.formats.
342364
mapstructure.TextUnmarshallerHookFunc(),
343365
))
344366
}

pkg/config/output.go

+53-9
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const (
2121
OutFormatTeamCity = "teamcity"
2222
)
2323

24-
var OutFormats = []string{
24+
var AllOutputFormats = []string{
2525
OutFormatColoredLineNumber,
2626
OutFormatLineNumber,
2727
OutFormatJSON,
@@ -35,14 +35,17 @@ var OutFormats = []string{
3535
}
3636

3737
type Output struct {
38-
Format string `mapstructure:"format"`
39-
PrintIssuedLine bool `mapstructure:"print-issued-lines"`
40-
PrintLinterName bool `mapstructure:"print-linter-name"`
41-
UniqByLine bool `mapstructure:"uniq-by-line"`
42-
SortResults bool `mapstructure:"sort-results"`
43-
SortOrder []string `mapstructure:"sort-order"`
44-
PathPrefix string `mapstructure:"path-prefix"`
45-
ShowStats bool `mapstructure:"show-stats"`
38+
Formats OutputFormats `mapstructure:"formats"`
39+
PrintIssuedLine bool `mapstructure:"print-issued-lines"`
40+
PrintLinterName bool `mapstructure:"print-linter-name"`
41+
UniqByLine bool `mapstructure:"uniq-by-line"`
42+
SortResults bool `mapstructure:"sort-results"`
43+
SortOrder []string `mapstructure:"sort-order"`
44+
PathPrefix string `mapstructure:"path-prefix"`
45+
ShowStats bool `mapstructure:"show-stats"`
46+
47+
// Deprecated: use Formats instead.
48+
Format string `mapstructure:"format"`
4649
}
4750

4851
func (o *Output) Validate() error {
@@ -64,5 +67,46 @@ func (o *Output) Validate() error {
6467
}
6568
}
6669

70+
for _, format := range o.Formats {
71+
err := format.Validate()
72+
if err != nil {
73+
return err
74+
}
75+
}
76+
77+
return nil
78+
}
79+
80+
type OutputFormat struct {
81+
Format string `mapstructure:"format"`
82+
Path string `mapstructure:"path"`
83+
}
84+
85+
func (o *OutputFormat) Validate() error {
86+
if o.Format == "" {
87+
return errors.New("the format is required")
88+
}
89+
90+
if !slices.Contains(AllOutputFormats, o.Format) {
91+
return fmt.Errorf("unsupported output format %q", o.Format)
92+
}
93+
94+
return nil
95+
}
96+
97+
type OutputFormats []OutputFormat
98+
99+
func (p *OutputFormats) UnmarshalText(text []byte) error {
100+
formats := strings.Split(string(text), ",")
101+
102+
for _, item := range formats {
103+
format, path, _ := strings.Cut(item, ":")
104+
105+
*p = append(*p, OutputFormat{
106+
Path: path,
107+
Format: format,
108+
})
109+
}
110+
67111
return nil
68112
}

pkg/config/output_test.go

+80
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,86 @@ func TestOutput_Validate_error(t *testing.T) {
8181
},
8282
expected: `the sort-order name "linter" is repeated several times`,
8383
},
84+
{
85+
desc: "unsupported format",
86+
settings: &Output{
87+
Formats: []OutputFormat{
88+
{
89+
Format: "test",
90+
},
91+
},
92+
},
93+
expected: `unsupported output format "test"`,
94+
},
95+
}
96+
97+
for _, test := range testCases {
98+
test := test
99+
t.Run(test.desc, func(t *testing.T) {
100+
t.Parallel()
101+
102+
err := test.settings.Validate()
103+
require.EqualError(t, err, test.expected)
104+
})
105+
}
106+
}
107+
108+
func TestOutputFormat_Validate(t *testing.T) {
109+
testCases := []struct {
110+
desc string
111+
settings *OutputFormat
112+
}{
113+
{
114+
desc: "only format",
115+
settings: &OutputFormat{
116+
Format: "json",
117+
},
118+
},
119+
{
120+
desc: "format and path (relative)",
121+
settings: &OutputFormat{
122+
Format: "json",
123+
Path: "./example.json",
124+
},
125+
},
126+
{
127+
desc: "format and path (absolute)",
128+
settings: &OutputFormat{
129+
Format: "json",
130+
Path: "/tmp/example.json",
131+
},
132+
},
133+
}
134+
135+
for _, test := range testCases {
136+
test := test
137+
t.Run(test.desc, func(t *testing.T) {
138+
t.Parallel()
139+
140+
err := test.settings.Validate()
141+
require.NoError(t, err)
142+
})
143+
}
144+
}
145+
146+
func TestOutputFormat_Validate_error(t *testing.T) {
147+
testCases := []struct {
148+
desc string
149+
settings *OutputFormat
150+
expected string
151+
}{
152+
{
153+
desc: "empty",
154+
settings: &OutputFormat{},
155+
expected: "the format is required",
156+
},
157+
{
158+
desc: "unsupported format",
159+
settings: &OutputFormat{
160+
Format: "test",
161+
},
162+
expected: `unsupported output format "test"`,
163+
},
84164
}
85165

86166
for _, test := range testCases {

0 commit comments

Comments
 (0)