github.com/vanstinator/golangci-lint@v0.0.0-20240223191551-cc572f00d9d1/pkg/config/reader.go (about)

     1  package config
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"strings"
     9  
    10  	"github.com/mitchellh/go-homedir"
    11  	"github.com/mitchellh/mapstructure"
    12  	"github.com/spf13/viper"
    13  	"golang.org/x/exp/slices"
    14  
    15  	"github.com/vanstinator/golangci-lint/pkg/exitcodes"
    16  	"github.com/vanstinator/golangci-lint/pkg/fsutils"
    17  	"github.com/vanstinator/golangci-lint/pkg/logutils"
    18  )
    19  
    20  type FileReader struct {
    21  	log            logutils.Log
    22  	cfg            *Config
    23  	commandLineCfg *Config
    24  }
    25  
    26  func NewFileReader(toCfg, commandLineCfg *Config, log logutils.Log) *FileReader {
    27  	return &FileReader{
    28  		log:            log,
    29  		cfg:            toCfg,
    30  		commandLineCfg: commandLineCfg,
    31  	}
    32  }
    33  
    34  func (r *FileReader) Read() error {
    35  	// XXX: hack with double parsing for 2 purposes:
    36  	// 1. to access "config" option here.
    37  	// 2. to give config less priority than command line.
    38  
    39  	configFile, err := r.parseConfigOption()
    40  	if err != nil {
    41  		if errors.Is(err, errConfigDisabled) {
    42  			return nil
    43  		}
    44  
    45  		return fmt.Errorf("can't parse --config option: %w", err)
    46  	}
    47  
    48  	if configFile != "" {
    49  		viper.SetConfigFile(configFile)
    50  
    51  		// Assume YAML if the file has no extension.
    52  		if filepath.Ext(configFile) == "" {
    53  			viper.SetConfigType("yaml")
    54  		}
    55  	} else {
    56  		r.setupConfigFileSearch()
    57  	}
    58  
    59  	return r.parseConfig()
    60  }
    61  
    62  func (r *FileReader) parseConfig() error {
    63  	if err := viper.ReadInConfig(); err != nil {
    64  		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
    65  			return nil
    66  		}
    67  
    68  		return fmt.Errorf("can't read viper config: %w", err)
    69  	}
    70  
    71  	usedConfigFile := viper.ConfigFileUsed()
    72  	if usedConfigFile == "" {
    73  		return nil
    74  	}
    75  
    76  	if usedConfigFile == os.Stdin.Name() {
    77  		usedConfigFile = ""
    78  		r.log.Infof("Reading config file stdin")
    79  	} else {
    80  		var err error
    81  		usedConfigFile, err = fsutils.ShortestRelPath(usedConfigFile, "")
    82  		if err != nil {
    83  			r.log.Warnf("Can't pretty print config file path: %v", err)
    84  		}
    85  
    86  		r.log.Infof("Used config file %s", usedConfigFile)
    87  	}
    88  
    89  	usedConfigDir, err := filepath.Abs(filepath.Dir(usedConfigFile))
    90  	if err != nil {
    91  		return errors.New("can't get config directory")
    92  	}
    93  	r.cfg.cfgDir = usedConfigDir
    94  
    95  	if err := viper.Unmarshal(r.cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
    96  		// Default hooks (https://github.com/spf13/viper/blob/518241257478c557633ab36e474dfcaeb9a3c623/viper.go#L135-L138).
    97  		mapstructure.StringToTimeDurationHookFunc(),
    98  		mapstructure.StringToSliceHookFunc(","),
    99  
   100  		// Needed for forbidigo.
   101  		mapstructure.TextUnmarshallerHookFunc(),
   102  	))); err != nil {
   103  		return fmt.Errorf("can't unmarshal config by viper: %w", err)
   104  	}
   105  
   106  	if err := r.validateConfig(); err != nil {
   107  		return fmt.Errorf("can't validate config: %w", err)
   108  	}
   109  
   110  	if r.cfg.InternalTest { // just for testing purposes: to detect config file usage
   111  		fmt.Fprintln(logutils.StdOut, "test")
   112  		os.Exit(exitcodes.Success)
   113  	}
   114  
   115  	return nil
   116  }
   117  
   118  func (r *FileReader) validateConfig() error {
   119  	c := r.cfg
   120  	if len(c.Run.Args) != 0 {
   121  		return errors.New("option run.args in config isn't supported now")
   122  	}
   123  
   124  	if c.Run.CPUProfilePath != "" {
   125  		return errors.New("option run.cpuprofilepath in config isn't allowed")
   126  	}
   127  
   128  	if c.Run.MemProfilePath != "" {
   129  		return errors.New("option run.memprofilepath in config isn't allowed")
   130  	}
   131  
   132  	if c.Run.TracePath != "" {
   133  		return errors.New("option run.tracepath in config isn't allowed")
   134  	}
   135  
   136  	if c.Run.IsVerbose {
   137  		return errors.New("can't set run.verbose option with config: only on command-line")
   138  	}
   139  	for i, rule := range c.Issues.ExcludeRules {
   140  		if err := rule.Validate(); err != nil {
   141  			return fmt.Errorf("error in exclude rule #%d: %w", i, err)
   142  		}
   143  	}
   144  	if len(c.Severity.Rules) > 0 && c.Severity.Default == "" {
   145  		return errors.New("can't set severity rule option: no default severity defined")
   146  	}
   147  	for i, rule := range c.Severity.Rules {
   148  		if err := rule.Validate(); err != nil {
   149  			return fmt.Errorf("error in severity rule #%d: %w", i, err)
   150  		}
   151  	}
   152  	if err := c.LintersSettings.Govet.Validate(); err != nil {
   153  		return fmt.Errorf("error in govet config: %w", err)
   154  	}
   155  	return nil
   156  }
   157  
   158  func getFirstPathArg() string {
   159  	args := os.Args
   160  
   161  	// skip all args ([golangci-lint, run/linters]) before files/dirs list
   162  	for len(args) != 0 {
   163  		if args[0] == "run" {
   164  			args = args[1:]
   165  			break
   166  		}
   167  
   168  		args = args[1:]
   169  	}
   170  
   171  	// find first file/dir arg
   172  	firstArg := "./..."
   173  	for _, arg := range args {
   174  		if !strings.HasPrefix(arg, "-") {
   175  			firstArg = arg
   176  			break
   177  		}
   178  	}
   179  
   180  	return firstArg
   181  }
   182  
   183  func (r *FileReader) setupConfigFileSearch() {
   184  	firstArg := getFirstPathArg()
   185  	absStartPath, err := filepath.Abs(firstArg)
   186  	if err != nil {
   187  		r.log.Warnf("Can't make abs path for %q: %s", firstArg, err)
   188  		absStartPath = filepath.Clean(firstArg)
   189  	}
   190  
   191  	// start from it
   192  	var curDir string
   193  	if fsutils.IsDir(absStartPath) {
   194  		curDir = absStartPath
   195  	} else {
   196  		curDir = filepath.Dir(absStartPath)
   197  	}
   198  
   199  	// find all dirs from it up to the root
   200  	configSearchPaths := []string{"./"}
   201  
   202  	for {
   203  		configSearchPaths = append(configSearchPaths, curDir)
   204  		newCurDir := filepath.Dir(curDir)
   205  		if curDir == newCurDir || newCurDir == "" {
   206  			break
   207  		}
   208  		curDir = newCurDir
   209  	}
   210  
   211  	// find home directory for global config
   212  	if home, err := homedir.Dir(); err != nil {
   213  		r.log.Warnf("Can't get user's home directory: %s", err.Error())
   214  	} else if !slices.Contains(configSearchPaths, home) {
   215  		configSearchPaths = append(configSearchPaths, home)
   216  	}
   217  
   218  	r.log.Infof("Config search paths: %s", configSearchPaths)
   219  	viper.SetConfigName(".golangci")
   220  	for _, p := range configSearchPaths {
   221  		viper.AddConfigPath(p)
   222  	}
   223  }
   224  
   225  var errConfigDisabled = errors.New("config is disabled by --no-config")
   226  
   227  func (r *FileReader) parseConfigOption() (string, error) {
   228  	cfg := r.commandLineCfg
   229  	if cfg == nil {
   230  		return "", nil
   231  	}
   232  
   233  	configFile := cfg.Run.Config
   234  	if cfg.Run.NoConfig && configFile != "" {
   235  		return "", errors.New("can't combine option --config and --no-config")
   236  	}
   237  
   238  	if cfg.Run.NoConfig {
   239  		return "", errConfigDisabled
   240  	}
   241  
   242  	configFile, err := homedir.Expand(configFile)
   243  	if err != nil {
   244  		return "", errors.New("failed to expand configuration path")
   245  	}
   246  
   247  	return configFile, nil
   248  }