Skip to content

feat: sharable config presets #2241

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .golangci.example.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# This file contains all available configuration options
# with their default values.

# configuration presets to use, each can be:
# - a file path beginning with '.', which will be resolved relative to this config file
# - a go module path, which will be searched for .golangci.yaml, .golangci.json etc
presets: []

# options for analysis running
run:
# default concurrency is a available CPU number
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ require (
github.com/stretchr/testify v1.7.0
github.com/tdakkota/asciicheck v0.0.0-20200416200610-e657995f937b
github.com/tetafro/godot v1.4.10
github.com/thepwagner/golangci-lint-yaml v0.0.1 // indirect
github.com/timakin/bodyclose v0.0.0-20200424151742-cb6215831a94
github.com/tomarrell/wrapcheck/v2 v2.3.0
github.com/tommy-muehle/go-mnd/v2 v2.4.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package config

type Config struct {
Run Run
Run Run
Presets []string

Output Output

Expand Down
127 changes: 117 additions & 10 deletions pkg/config/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/mitchellh/go-homedir"
"github.com/spf13/viper"
"golang.org/x/tools/go/packages"

"github.com/golangci/golangci-lint/pkg/fsutils"
"github.com/golangci/golangci-lint/pkg/logutils"
Expand Down Expand Up @@ -43,25 +45,26 @@ func (r *FileReader) Read() error {
return fmt.Errorf("can't parse --config option: %s", err)
}

v := viper.New()
if configFile != "" {
viper.SetConfigFile(configFile)
v.SetConfigFile(configFile)
} else {
r.setupConfigFileSearch()
r.setupConfigFileSearch(v)
}

return r.parseConfig()
return r.parseConfig(v)
}

func (r *FileReader) parseConfig() error {
if err := viper.ReadInConfig(); err != nil {
func (r *FileReader) parseConfig(v *viper.Viper) error {
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
return nil
}

return fmt.Errorf("can't read viper config: %s", err)
}

usedConfigFile := viper.ConfigFileUsed()
usedConfigFile := v.ConfigFileUsed()
if usedConfigFile == "" {
return nil
}
Expand All @@ -72,10 +75,14 @@ func (r *FileReader) parseConfig() error {
}
r.log.Infof("Used config file %s", usedConfigFile)

if err := viper.Unmarshal(r.cfg); err != nil {
if err := v.Unmarshal(r.cfg); err != nil {
return fmt.Errorf("can't unmarshal config by viper: %s", err)
}

if err := r.mergePresets(v); err != nil {
return fmt.Errorf("can't merge presets: %s", err)
}

if err := r.validateConfig(); err != nil {
return fmt.Errorf("can't validate config: %s", err)
}
Expand Down Expand Up @@ -128,6 +135,105 @@ func (r *FileReader) validateConfig() error {
return nil
}

func (r *FileReader) mergePresets(v *viper.Viper) error {
if len(r.cfg.Presets) == 0 {
return nil
}
presets, err := r.loadPresets(v)
if err != nil {
return err
}

// Merge via viper, with special handling for .linters.{en,dis}abled slice
mergedV := viper.New()
lintersEnabled := map[string]struct{}{}
lintersDisabled := map[string]struct{}{}
for _, cfg := range append(presets, v) {
if err := mergedV.MergeConfigMap(cfg.AllSettings()); err != nil {
return fmt.Errorf("can't merge config %q: %w", cfg.ConfigFileUsed(), err)
}

for _, l := range cfg.GetStringSlice("linters.enable") {
lintersEnabled[l] = struct{}{}
delete(lintersDisabled, l)
}
for _, l := range cfg.GetStringSlice("linters.disable") {
lintersDisabled[l] = struct{}{}
delete(lintersEnabled, l)
}
}

if err := mergedV.Unmarshal(r.cfg); err != nil {
return fmt.Errorf("can't unmarshal merged config: %w", err)
}

if len(lintersEnabled) > 0 {
r.cfg.Linters.Enable = make([]string, 0, len(lintersEnabled))
for l := range lintersEnabled {
r.cfg.Linters.Enable = append(r.cfg.Linters.Enable, l)
}
sort.Strings(r.cfg.Linters.Enable)
} else {
r.cfg.Linters.Enable = nil
}
if len(lintersDisabled) > 0 {
r.cfg.Linters.Disable = make([]string, 0, len(lintersDisabled))
for l := range lintersDisabled {
r.cfg.Linters.Disable = append(r.cfg.Linters.Disable, l)
}
sort.Strings(r.cfg.Linters.Disable)
} else {
r.cfg.Linters.Disable = nil
}

return nil
}

const configName = ".golangci"

func (r *FileReader) loadPresets(v *viper.Viper) ([]*viper.Viper, error) {
cfgBase := filepath.Dir(v.ConfigFileUsed())
presets := make([]*viper.Viper, 0, len(r.cfg.Presets))
for _, preset := range r.cfg.Presets {
presetV := viper.New()
if strings.HasPrefix(preset, ".") {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It means the local path always should be started with ./ ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or ../. The pattern for distinguishing modules from local paths is inspired by the right hand side of replace directives in go.mod.

If the path on the right side of the arrow is an absolute or relative path (beginning with ./ or ../), it is interpreted as the local file path to the replacement module root directory, which must contain a go.mod file.
[...]
If the path on the right side is not a local path, it must be a valid module path.

/ might also be a valid prefix to trigger file loading that I missed did not think would be used.

cfgFile := filepath.Join(cfgBase, preset)
presetV.SetConfigFile(cfgFile)
} else {
modDir, err := findModDir(preset)
if err != nil {
return nil, err
}
presetV.AddConfigPath(modDir)
presetV.SetConfigName(configName)
}

if err := presetV.ReadInConfig(); err != nil {
return nil, fmt.Errorf("can't read preset config %q: %w", preset, err)
}
r.log.Infof("Read preset module config file %s", presetV.ConfigFileUsed())

presets = append(presets, presetV)
}
return presets, nil
}

func findModDir(mod string) (string, error) {
cfg := &packages.Config{Mode: packages.NeedFiles}
pkgs, err := packages.Load(cfg, mod)
if err != nil {
return "", fmt.Errorf("can't load preset module %q: %w", mod, err)
}
pkg := pkgs[0]
if len(pkg.Errors) > 0 {
return "", fmt.Errorf("can't load preset module package %q: %+v", mod, pkg.Errors)
}
if len(pkg.GoFiles) == 0 {
return "", fmt.Errorf("empty preset module package: add at least one .go file %s", mod)
}
return filepath.Dir(pkg.GoFiles[0]), nil
}

func getFirstPathArg() string {
args := os.Args

Expand All @@ -153,7 +259,7 @@ func getFirstPathArg() string {
return firstArg
}

func (r *FileReader) setupConfigFileSearch() {
func (r *FileReader) setupConfigFileSearch(v *viper.Viper) {
firstArg := getFirstPathArg()
absStartPath, err := filepath.Abs(firstArg)
if err != nil {
Expand Down Expand Up @@ -189,9 +295,10 @@ func (r *FileReader) setupConfigFileSearch() {
}

r.log.Infof("Config search paths: %s", configSearchPaths)
viper.SetConfigName(".golangci")

v.SetConfigName(configName)
for _, p := range configSearchPaths {
viper.AddConfigPath(p)
v.AddConfigPath(p)
}
}

Expand Down
73 changes: 73 additions & 0 deletions pkg/config/reader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package config

import (
"path/filepath"
"testing"

"github.com/golangci/golangci-lint/pkg/logutils"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func TestFileReader_Read(t *testing.T) {
cases := map[string]Config{
"preset_parent.yml": {
Linters: Linters{
EnableAll: true,
Fast: true,
Enable: []string{"deadcode", "errcheck", "gosimple"},
},
},
"preset_single.yml": {
Linters: Linters{
Fast: true,
Enable: []string{"errcheck", "gosimple", "govet"},
Disable: []string{"deadcode"},
},
Presets: []string{"./preset_parent.yml"},
},
"preset_double.yml": {
Linters: Linters{
Enable: []string{"errcheck", "gosimple", "govet"},
Disable: []string{"deadcode"},
},
Output: Output{
PrintIssuedLine: true,
},
Presets: []string{"./preset_parent.yml", "./preset_single.yml"},
},
"preset_order.yml": {
Linters: Linters{
Fast: true,
Disable: []string{"deadcode", "errcheck", "gosimple", "govet"},
},
Presets: []string{"./preset_parent.yml", "./preset_single.yml", "./disable_all.yml"},
},
}

for cfgFile, expected := range cases {
t.Run(cfgFile, func(t *testing.T) {
cfg := readConfig(t, cfgFile)
assert.Equal(t, expected, cfg)
})
}
}

func TestFileReader_Read_Module(t *testing.T) {
cfg := readConfig(t, "preset_module.yml")
assert.NotEmpty(t, cfg.Linters.Enable)
}

func readConfig(t *testing.T, fn string) Config {
var cfg, cmdCfg Config
cmdCfg.Run.Config = filepath.Join("..", "..", "test", "testdata", "configs", fn)
log := logutils.NewMockLog()
log.On("Infof", mock.Anything, mock.Anything).Maybe()

r := NewFileReader(&cfg, &cmdCfg, log)
err := r.Read()
require.NoError(t, err)
return cfg
}
6 changes: 6 additions & 0 deletions test/testdata/configs/disable_all.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
linters:
disable:
- deadcode
- errcheck
- gosimple
- govet
11 changes: 11 additions & 0 deletions test/testdata/configs/preset_double.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
linters:
# overwrite the value in preset_single.yml
fast: false

# add a new value
output:
print-issued-lines: true

presets:
- ./preset_parent.yml
- ./preset_single.yml
3 changes: 3 additions & 0 deletions test/testdata/configs/preset_module.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# TODO: host a preset in the golangci org?
presets:
- github.com/thepwagner/golangci-lint-yaml
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could it be located in golangci-lint/test/testdata ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, but I'm not sold on that path: the goal of this test is to exercise loading the configuration from a go module.

Using a replace directive, we could load from a go module that happens to be housed at ./test/testdata/...:

require github.com/golangci/golangci-lint-yaml v0.0.1
replace github.com/golangci/golangci-lint-yaml => ./test/testdata/some-new-dir

I figured since #1141 mentions "presets like golangci:recommended", using such a preset here would be the best solution. To shoe-horn that idea into this proposal, a module like github.com/golangci/golangci-recommended would be the equivalent of golangci:recommended preset.


Any repo with a .golangci.yml should work - using an existing dependency like github.com/ldez/gomoddirectives would have been less egregious to the dependency graph, but I thought the pattern of a module containing only the configuration was more interesting to include in the proposal.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see.

In my case I would like have any github or https path (not exactly the go module or some convention of names).

presets:
  - local/path/config.yml
  - https://raw.githubusercontent.com/golangci/golangci-lint/master/.pre-commit-hooks.yaml
  - github:golangci/golangci-lint/master/subfolder/docs/common.yaml

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any https path

Per #2137 (comment) , I don't think https:// paths would be a welcome addition. If you want an HTTPS resource, they can be curl-ed first and used as a file preset.

any github path

That might be useful, e.g. to have multiple configurations in a single repository.

Would it be acceptable for that path to also be a valid go module? Again, I wanted to avoid adding outgoing network calls, authentication etc. Only using files provided by go mod is an attempt to walk that line.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It make sense. Thank you for the hint.

In this case then I would propose first support only local path and think about go mod solution in separate PR.

5 changes: 5 additions & 0 deletions test/testdata/configs/preset_order.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
presets:
- ./preset_parent.yml
- ./preset_single.yml
# appears last, overwriting previously enabled checks
- ./disable_all.yml
7 changes: 7 additions & 0 deletions test/testdata/configs/preset_parent.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
linters:
enable-all: true
fast: true
enable:
- deadcode
- errcheck
- gosimple
11 changes: 11 additions & 0 deletions test/testdata/configs/preset_single.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
linters:
# overwrite a value in preset_parent.yml
enable-all: false
# enable and disable a linter
enable:
- govet
disable:
- deadcode

presets:
- ./preset_parent.yml