package config import ( "errors" "fmt" "os" "path/filepath" "slices" "strings" "github.com/go-viper/mapstructure/v2" "github.com/mitchellh/go-homedir" "github.com/spf13/pflag" "github.com/spf13/viper" "github.com/golangci/golangci-lint/pkg/exitcodes" "github.com/golangci/golangci-lint/pkg/fsutils" "github.com/golangci/golangci-lint/pkg/logutils" ) var errConfigDisabled = errors.New("config is disabled by --no-config") type LoaderOptions struct { Config string // Flag only. The path to the golangci config file, as specified with the --config argument. NoConfig bool // Flag only. } type Loader struct { opts LoaderOptions viper *viper.Viper fs *pflag.FlagSet log logutils.Log cfg *Config } func NewLoader(log logutils.Log, v *viper.Viper, fs *pflag.FlagSet, opts LoaderOptions, cfg *Config) *Loader { return &Loader{ opts: opts, viper: v, fs: fs, log: log, cfg: cfg, } } func (l *Loader) Load() error { err := l.setConfigFile() if err != nil { return err } err = l.parseConfig() if err != nil { return err } l.applyStringSliceHack() l.handleGoVersion() return nil } func (l *Loader) handleGoVersion() { if l.cfg.Run.Go == "" { l.cfg.Run.Go = detectGoVersion() } l.cfg.LintersSettings.Govet.Go = l.cfg.Run.Go l.cfg.LintersSettings.ParallelTest.Go = l.cfg.Run.Go trimmedGoVersion := trimGoVersion(l.cfg.Run.Go) l.cfg.LintersSettings.Gocritic.Go = trimmedGoVersion if l.cfg.LintersSettings.Gofumpt.LangVersion == "" { l.cfg.LintersSettings.Gofumpt.LangVersion = l.cfg.Run.Go } // staticcheck related linters. if l.cfg.LintersSettings.Staticcheck.GoVersion == "" { l.cfg.LintersSettings.Staticcheck.GoVersion = trimmedGoVersion } if l.cfg.LintersSettings.Gosimple.GoVersion == "" { l.cfg.LintersSettings.Gosimple.GoVersion = trimmedGoVersion } if l.cfg.LintersSettings.Stylecheck.GoVersion != "" { l.cfg.LintersSettings.Stylecheck.GoVersion = trimmedGoVersion } } func (l *Loader) setConfigFile() error { configFile, err := l.evaluateOptions() if err != nil { if errors.Is(err, errConfigDisabled) { return nil } return fmt.Errorf("can't parse --config option: %w", err) } if configFile != "" { l.viper.SetConfigFile(configFile) // Assume YAML if the file has no extension. if filepath.Ext(configFile) == "" { l.viper.SetConfigType("yaml") } } else { l.setupConfigFileSearch() } return nil } func (l *Loader) evaluateOptions() (string, error) { if l.opts.NoConfig && l.opts.Config != "" { return "", errors.New("can't combine option --config and --no-config") } if l.opts.NoConfig { return "", errConfigDisabled } configFile, err := homedir.Expand(l.opts.Config) if err != nil { return "", errors.New("failed to expand configuration path") } return configFile, nil } func (l *Loader) setupConfigFileSearch() { firstArg := extractFirstPathArg() absStartPath, err := filepath.Abs(firstArg) if err != nil { l.log.Warnf("Can't make abs path for %q: %s", firstArg, err) absStartPath = filepath.Clean(firstArg) } // start from it var curDir string if fsutils.IsDir(absStartPath) { curDir = absStartPath } else { curDir = filepath.Dir(absStartPath) } // find all dirs from it up to the root configSearchPaths := []string{"./"} for { configSearchPaths = append(configSearchPaths, curDir) newCurDir := filepath.Dir(curDir) if curDir == newCurDir || newCurDir == "" { break } curDir = newCurDir } // find home directory for global config if home, err := homedir.Dir(); err != nil { l.log.Warnf("Can't get user's home directory: %s", err.Error()) } else if !slices.Contains(configSearchPaths, home) { configSearchPaths = append(configSearchPaths, home) } l.log.Infof("Config search paths: %s", configSearchPaths) l.viper.SetConfigName(".golangci") for _, p := range configSearchPaths { l.viper.AddConfigPath(p) } } func (l *Loader) parseConfig() error { if err := l.viper.ReadInConfig(); err != nil { var configFileNotFoundError viper.ConfigFileNotFoundError if errors.As(err, &configFileNotFoundError) { // Load configuration from flags only. err = l.viper.Unmarshal(l.cfg) if err != nil { return fmt.Errorf("can't unmarshal config by viper (flags): %w", err) } return nil } return fmt.Errorf("can't read viper config: %w", err) } err := l.setConfigDir() if err != nil { return err } // Load configuration from all sources (flags, file). if err := l.viper.Unmarshal(l.cfg, fileDecoderHook()); err != nil { return fmt.Errorf("can't unmarshal config by viper (flags, file): %w", err) } if l.cfg.InternalTest { // just for testing purposes: to detect config file usage _, _ = fmt.Fprintln(logutils.StdOut, "test") os.Exit(exitcodes.Success) } return nil } func (l *Loader) setConfigDir() error { usedConfigFile := l.viper.ConfigFileUsed() if usedConfigFile == "" { return nil } if usedConfigFile == os.Stdin.Name() { usedConfigFile = "" l.log.Infof("Reading config file stdin") } else { var err error usedConfigFile, err = fsutils.ShortestRelPath(usedConfigFile, "") if err != nil { l.log.Warnf("Can't pretty print config file path: %v", err) } l.log.Infof("Used config file %s", usedConfigFile) } usedConfigDir, err := filepath.Abs(filepath.Dir(usedConfigFile)) if err != nil { return errors.New("can't get config directory") } l.cfg.cfgDir = usedConfigDir return nil } // Hack to append values from StringSlice flags. // Viper always overrides StringSlice values. // https://github.com/spf13/viper/issues/1448 // So StringSlice flags are not bind to Viper like that their values are obtain via Cobra Flags. func (l *Loader) applyStringSliceHack() { if l.fs == nil { return } l.appendStringSlice("enable", &l.cfg.Linters.Enable) l.appendStringSlice("disable", &l.cfg.Linters.Disable) l.appendStringSlice("presets", &l.cfg.Linters.Presets) l.appendStringSlice("build-tags", &l.cfg.Run.BuildTags) l.appendStringSlice("skip-dirs", &l.cfg.Run.SkipDirs) l.appendStringSlice("skip-files", &l.cfg.Run.SkipFiles) l.appendStringSlice("exclude", &l.cfg.Issues.ExcludePatterns) } func (l *Loader) appendStringSlice(name string, current *[]string) { if l.fs.Changed(name) { val, _ := l.fs.GetStringSlice(name) *current = append(*current, val...) } } func fileDecoderHook() viper.DecoderConfigOption { return viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( // Default hooks (https://github.com/spf13/viper/blob/518241257478c557633ab36e474dfcaeb9a3c623/viper.go#L135-L138). mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToSliceHookFunc(","), // Needed for forbidigo. mapstructure.TextUnmarshallerHookFunc(), )) } func extractFirstPathArg() string { args := os.Args // skip all args ([golangci-lint, run/linters]) before files/dirs list for len(args) != 0 { if args[0] == "run" { args = args[1:] break } args = args[1:] } // find first file/dir arg firstArg := "./..." for _, arg := range args { if !strings.HasPrefix(arg, "-") { firstArg = arg break } } return firstArg }