Repository: VKCOM/noverify
Pull request #895 (2021-01-15 23:47:56)

WIP: all: make linter config explicit (instead of being global vars)
Since global usages were chaotic, this change affected a lot of files.


Patch:
diff --git a/src/cmd/check.go b/src/cmd/check.go
index f5f2e19..0311103 100644
--- a/src/cmd/check.go
+++ b/src/cmd/check.go
@@ -20,11 +20,15 @@ func Check(cfg *MainConfig) (int, error) {
 		linter.DeclareRules(rset)
 	}
 
+	config := cfg.LinterConfig
+	if config == nil {
+		config = linter.NewConfig()
+	}
 	var args cmdlineArguments
-	bindFlags(ruleSets, &args)
+	bindFlags(config, ruleSets, &args)
 	flag.Parse()
 	if args.disableCache {
-		linter.CacheDir = ""
+		config.CacheDir = ""
 	}
 	if cfg.AfterFlagParse != nil {
 		cfg.AfterFlagParse(InitEnvironment{
@@ -32,5 +36,5 @@ func Check(cfg *MainConfig) (int, error) {
 		})
 	}
 
-	return mainNoExit(ruleSets, &args, cfg)
+	return mainNoExit(config, ruleSets, &args, cfg)
 }
diff --git a/src/cmd/conf.go b/src/cmd/conf.go
index b3b0338..eb49ddd 100644
--- a/src/cmd/conf.go
+++ b/src/cmd/conf.go
@@ -27,6 +27,8 @@ type MainConfig struct {
 
 	LinterVersion string
 
+	LinterConfig *linter.Config
+
 	// OverriddenCommands is a list of new commands and
 	// commands that override existing commands.
 	OverriddenCommands *Commands
diff --git a/src/cmd/flags.go b/src/cmd/flags.go
index e6cd9fa..d389459 100644
--- a/src/cmd/flags.go
+++ b/src/cmd/flags.go
@@ -22,7 +22,7 @@ type cmdlineArguments struct {
 	cpuProfile string
 	memProfile string
 
-	fix bool
+	maxFileSize int
 
 	fullAnalysisFiles string
 	indexOnlyFiles    string
@@ -33,8 +33,7 @@ type cmdlineArguments struct {
 	outputJSON     bool
 	outputBaseline bool
 
-	baseline             string
-	conservativeBaseline bool
+	baseline string
 
 	misspellList string
 
@@ -51,7 +50,6 @@ type cmdlineArguments struct {
 	phpExtensionsArg string
 
 	gitignore bool
-	kphp      bool
 
 	gitPushArg                 string
 	gitAuthorsWhitelist        string
@@ -84,7 +82,7 @@ func DefaultCacheDir() string {
 	return defaultCacheDir
 }
 
-func bindFlags(ruleSets []*rules.Set, args *cmdlineArguments) {
+func bindFlags(config *linter.Config, ruleSets []*rules.Set, args *cmdlineArguments) {
 	var enabledByDefault []string
 	declaredChecks := linter.GetDeclaredChecks()
 	for _, info := range declaredChecks {
@@ -124,7 +122,7 @@ func bindFlags(ruleSets []*rules.Set, args *cmdlineArguments) {
 
 	flag.StringVar(&args.baseline, "baseline", "",
 		"Path to a suppress profile created by -output-baseline")
-	flag.BoolVar(&args.conservativeBaseline, "conservative-baseline", false,
+	flag.BoolVar(&config.ConservativeBaseline, "conservative-baseline", false,
 		"If enabled, baseline mode will have less false positive, but more false negatives")
 
 	flag.StringVar(&args.reportsCritical, "critical", allNonMaybe,
@@ -133,12 +131,12 @@ func bindFlags(ruleSets []*rules.Set, args *cmdlineArguments) {
 	flag.StringVar(&args.rulesList, "rules", "",
 		"Comma-separated list of rules files")
 
-	flag.BoolVar(&args.fix, "fix", false,
+	flag.BoolVar(&config.ApplyQuickFixes, "fix", false,
 		"Apply a quickfix where possible (updates source files)")
 
 	flag.BoolVar(&args.gitignore, "gitignore", false,
 		"If enabled, noverify tries to use .gitignore files to exclude matched ignored files from the analysis")
-	flag.BoolVar(&args.kphp, "kphp", false,
+	flag.BoolVar(&config.KPHP, "kphp", false,
 		"If enabled, treat the code as KPHP")
 
 	flag.StringVar(&args.gitRepo, "git", "", "Path to git repository to analyze")
@@ -172,14 +170,14 @@ func bindFlags(ruleSets []*rules.Set, args *cmdlineArguments) {
 	flag.BoolVar(&args.outputJSON, "output-json", false, "Format output as JSON")
 	flag.BoolVar(&args.outputBaseline, "output-baseline", false, "Output a suppression profile instead of reports")
 
-	flag.BoolVar(&linter.CheckAutoGenerated, `check-auto-generated`, false, "Whether to lint auto-generated PHP file")
-	flag.BoolVar(&linter.Debug, "debug", false, "Enable debug output")
-	flag.DurationVar(&linter.DebugParseDuration, "debug-parse-duration", 0, "Print files that took longer than the specified time to analyse")
-	flag.IntVar(&linter.MaxFileSize, "max-sum-filesize", 20*1024*1024, "Max total file size to be parsed concurrently in bytes (limits max memory consumption)")
-	flag.IntVar(&linter.MaxConcurrency, "cores", runtime.NumCPU(), "Max cores")
+	flag.BoolVar(&config.CheckAutoGenerated, `check-auto-generated`, false, "Whether to lint auto-generated PHP file")
+	flag.BoolVar(&config.Debug, "debug", false, "Enable debug output")
+	flag.DurationVar(&config.DebugParseDuration, "debug-parse-duration", 0, "Print files that took longer than the specified time to analyse")
+	flag.IntVar(&args.maxFileSize, "max-sum-filesize", 20*1024*1024, "Max total file size to be parsed concurrently in bytes (limits max memory consumption)")
+	flag.IntVar(&config.MaxConcurrency, "cores", runtime.NumCPU(), "Max cores")
 
-	flag.StringVar(&linter.StubsDir, "stubs-dir", "", "Directory with phpstorm-stubs")
-	flag.StringVar(&linter.CacheDir, "cache-dir", DefaultCacheDir(), "Directory for linter cache (greatly improves indexing speed)")
+	flag.StringVar(&config.StubsDir, "stubs-dir", "", "Directory with phpstorm-stubs")
+	flag.StringVar(&config.CacheDir, "cache-dir", DefaultCacheDir(), "Directory for linter cache (greatly improves indexing speed)")
 	flag.BoolVar(&args.disableCache, "disable-cache", false, "If set, cache is not used and cache-dir is ignored")
 
 	flag.StringVar(&args.unusedVarPattern, "unused-var-regex", `^_$`,
diff --git a/src/cmd/git_main.go b/src/cmd/git_main.go
index d133df0..6989957 100644
--- a/src/cmd/git_main.go
+++ b/src/cmd/git_main.go
@@ -22,7 +22,7 @@ func gitParseUntracked(l *linterRunner) []*linter.Report {
 		log.Fatalf("get untracked files: %v", err)
 	}
 
-	return linter.ParseFilenames(workspace.ReadFilenames(filenames, nil), l.allowDisableRegex)
+	return l.getLinter().AnalyzeFiles(workspace.ReadFilenames(filenames, nil))
 }
 
 func parseIndexOnlyFiles(l *linterRunner) {
@@ -30,7 +30,7 @@ func parseIndexOnlyFiles(l *linterRunner) {
 		return
 	}
 	filenames := strings.Split(l.args.indexOnlyFiles, ",")
-	linter.ParseFilenames(workspace.ReadFilenames(filenames, nil), l.allowDisableRegex)
+	l.getLinter().AnalyzeFiles(workspace.ReadFilenames(filenames, nil))
 }
 
 // Not the best name, and not the best function signature.
@@ -54,57 +54,57 @@ func gitRepoComputeReportsFromCommits(l *linterRunner, logArgs, diffArgs []strin
 
 	if l.args.gitFullDiff {
 		meta.ResetInfo()
-		if err := loadEmbeddedStubs(); err != nil {
+		if err := loadEmbeddedStubs(l.config); err != nil {
 			log.Panicf("Load embedded stubs: %v", err)
 		}
 
 		start := time.Now()
-		linter.ParseFilenames(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitFrom, nil), l.allowDisableRegex)
+		l.getLinter().AnalyzeFiles(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitFrom, nil))
 		parseIndexOnlyFiles(l)
 		log.Printf("Indexed old commit in %s", time.Since(start))
 
 		meta.SetIndexingComplete(true)
 
 		start = time.Now()
-		oldReports = linter.ParseFilenames(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitFrom, linter.ExcludeRegex), l.allowDisableRegex)
+		oldReports = l.getLinter().AnalyzeFiles(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitFrom, l.config.ExcludeRegex))
 		log.Printf("Parsed old commit for %s (%d reports)", time.Since(start), len(oldReports))
 
 		meta.ResetInfo()
-		if err := loadEmbeddedStubs(); err != nil {
+		if err := loadEmbeddedStubs(l.config); err != nil {
 			log.Panicf("Load embedded stubs: %v", err)
 		}
 
 		start = time.Now()
 		parseIndexOnlyFiles(l)
-		linter.ParseFilenames(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitTo, nil), l.allowDisableRegex)
+		l.getLinter().AnalyzeFiles(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitTo, nil))
 		log.Printf("Indexed new commit in %s", time.Since(start))
 
 		meta.SetIndexingComplete(true)
 
 		start = time.Now()
-		reports = linter.ParseFilenames(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitTo, linter.ExcludeRegex), l.allowDisableRegex)
+		reports = l.getLinter().AnalyzeFiles(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitTo, l.config.ExcludeRegex))
 		log.Printf("Parsed new commit in %s (%d reports)", time.Since(start), len(reports))
 	} else {
 		start := time.Now()
-		linter.ParseFilenames(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitTo, nil), l.allowDisableRegex)
+		l.getLinter().AnalyzeFiles(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitTo, nil))
 		parseIndexOnlyFiles(l)
 		log.Printf("Indexing complete in %s", time.Since(start))
 
 		meta.SetIndexingComplete(true)
 
 		start = time.Now()
-		oldReports = linter.ParseFilenames(workspace.ReadOldFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitFrom, changes), l.allowDisableRegex)
+		oldReports = l.getLinter().AnalyzeFiles(workspace.ReadOldFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitFrom, changes))
 		log.Printf("Parsed old files versions for %s", time.Since(start))
 
 		start = time.Now()
 		meta.SetIndexingComplete(false)
 		parseIndexOnlyFiles(l)
-		linter.ParseFilenames(workspace.ReadFilesFromGitWithChanges(l.args.gitRepo, l.args.mutable.gitCommitTo, changes), l.allowDisableRegex)
+		l.getLinter().AnalyzeFiles(workspace.ReadFilesFromGitWithChanges(l.args.gitRepo, l.args.mutable.gitCommitTo, changes))
 		meta.SetIndexingComplete(true)
 		log.Printf("Indexed files versions for %s", time.Since(start))
 
 		start = time.Now()
-		reports = linter.ParseFilenames(workspace.ReadFilesFromGitWithChanges(l.args.gitRepo, l.args.mutable.gitCommitTo, changes), l.allowDisableRegex)
+		reports = l.getLinter().AnalyzeFiles(workspace.ReadFilesFromGitWithChanges(l.args.gitRepo, l.args.mutable.gitCommitTo, changes))
 		log.Printf("Parsed new file versions in %s", time.Since(start))
 	}
 
@@ -131,26 +131,26 @@ func gitRepoComputeReportsFromLocalChanges(l *linterRunner) (oldReports, reports
 	log.Printf("You have changes in your work tree, showing diff between %s and work tree", l.args.mutable.gitCommitFrom)
 
 	start := time.Now()
-	linter.ParseFilenames(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitFrom, nil), l.allowDisableRegex)
+	l.getLinter().AnalyzeFiles(workspace.ReadFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitFrom, nil))
 	parseIndexOnlyFiles(l)
 	log.Printf("Indexing complete in %s", time.Since(start))
 
 	meta.SetIndexingComplete(true)
 
 	start = time.Now()
-	oldReports = linter.ParseFilenames(workspace.ReadOldFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitFrom, changes), l.allowDisableRegex)
+	oldReports = l.getLinter().AnalyzeFiles(workspace.ReadOldFilesFromGit(l.args.gitRepo, l.args.mutable.gitCommitFrom, changes))
 	log.Printf("Parsed old files versions for %s", time.Since(start))
 
 	start = time.Now()
 	meta.SetIndexingComplete(false)
-	linter.ParseFilenames(workspace.ReadChangesFromWorkTree(l.args.gitWorkTree, changes), l.allowDisableRegex)
+	l.getLinter().AnalyzeFiles(workspace.ReadChangesFromWorkTree(l.args.gitWorkTree, changes))
 	parseIndexOnlyFiles(l)
 	gitParseUntracked(l)
 	meta.SetIndexingComplete(true)
 	log.Printf("Indexed new files versions for %s", time.Since(start))
 
 	start = time.Now()
-	reports = linter.ParseFilenames(workspace.ReadChangesFromWorkTree(l.args.gitWorkTree, changes), l.allowDisableRegex)
+	reports = l.getLinter().AnalyzeFiles(workspace.ReadChangesFromWorkTree(l.args.gitWorkTree, changes))
 	reports = append(reports, gitParseUntracked(l)...)
 	log.Printf("Parsed new file versions in %s", time.Since(start))
 
diff --git a/src/cmd/help.go b/src/cmd/help.go
index 6292f3d..dba92dd 100644
--- a/src/cmd/help.go
+++ b/src/cmd/help.go
@@ -46,8 +46,8 @@ func Help(*MainConfig) (int, error) {
 
 func declareRules() {
 	p := rules.NewParser()
-	linter.Rules = rules.NewSet()
-	ruleSets, err := InitEmbeddedRules(p, func(r rules.Rule) bool { return true })
+	config := linter.NewConfig()
+	ruleSets, err := InitEmbeddedRules(config, p, func(r rules.Rule) bool { return true })
 	if err != nil {
 		panic(err)
 	}
diff --git a/src/cmd/linter_runner.go b/src/cmd/linter_runner.go
index 609c6c8..300bc42 100644
--- a/src/cmd/linter_runner.go
+++ b/src/cmd/linter_runner.go
@@ -11,6 +11,7 @@ import (
 	"github.com/client9/misspell"
 
 	"github.com/VKCOM/noverify/src/baseline"
+	"github.com/VKCOM/noverify/src/lintdebug"
 	"github.com/VKCOM/noverify/src/linter"
 	"github.com/VKCOM/noverify/src/rules"
 	"github.com/VKCOM/noverify/src/workspace"
@@ -19,6 +20,10 @@ import (
 type linterRunner struct {
 	args *cmdlineArguments
 
+	linter *linter.Linter
+
+	config *linter.Config
+
 	outputFp io.Writer
 
 	filenameFilter *workspace.FilenameFilter
@@ -26,8 +31,13 @@ type linterRunner struct {
 	reportsExcludeChecksSet map[string]bool
 	reportsIncludeChecksSet map[string]bool
 	reportsCriticalSet      map[string]bool
+}
 
-	allowDisableRegex *regexp.Regexp
+func (l *linterRunner) getLinter() *linter.Linter {
+	if l.linter == nil {
+		l.linter = linter.NewLinter(l.config)
+	}
+	return l.linter
 }
 
 func (l *linterRunner) IsEnabledByFlags(checkName string) bool {
@@ -43,7 +53,7 @@ func (l *linterRunner) IsEnabledByFlags(checkName string) bool {
 }
 
 func (l *linterRunner) collectGitIgnoreFiles() error {
-	l.filenameFilter = workspace.NewFilenameFilter(linter.ExcludeRegex)
+	l.filenameFilter = workspace.NewFilenameFilter(l.config.ExcludeRegex)
 
 	if !l.args.gitignore {
 		return nil
@@ -68,7 +78,7 @@ func (l *linterRunner) collectGitIgnoreFiles() error {
 			l.filenameFilter.InitialGitignorePush(dir, m)
 		}
 		if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
-			linter.DebugMessage("discovered git top level: %s", dir)
+			lintdebug.Send("discovered git top level: %s", dir)
 			break
 		}
 		parentDir := filepath.Dir(dir)
@@ -97,15 +107,12 @@ func (l *linterRunner) Init(ruleSets []*rules.Set, args *cmdlineArguments) error
 		l.outputFp = outputFp
 	}
 
-	linter.ApplyQuickFixes = l.args.fix
-	linter.KPHP = l.args.kphp
-
 	if err := l.compileRegexes(); err != nil {
 		return err
 	}
 
 	if args.misspellList != "" {
-		err := LoadMisspellDicts(strings.Split(args.misspellList, ","))
+		err := LoadMisspellDicts(l.config, strings.Split(args.misspellList, ","))
 		if err != nil {
 			return err
 		}
@@ -123,7 +130,6 @@ func (l *linterRunner) Init(ruleSets []*rules.Set, args *cmdlineArguments) error
 }
 
 func (l *linterRunner) initBaseline() error {
-	linter.ConservativeBaseline = l.args.conservativeBaseline
 	if l.args.baseline == "" {
 		return nil
 	}
@@ -137,14 +143,14 @@ func (l *linterRunner) initBaseline() error {
 	if err != nil {
 		return err
 	}
-	linter.BaselineProfile = profile
+	l.config.BaselineProfile = profile
 	return nil
 }
 
 func (l *linterRunner) compileRegexes() error {
 	if l.args.reportsExclude != "" {
 		var err error
-		linter.ExcludeRegex, err = regexp.Compile(l.args.reportsExclude)
+		l.config.ExcludeRegex, err = regexp.Compile(l.args.reportsExclude)
 		if err != nil {
 			return fmt.Errorf("incorrect exclude regex: %v", err)
 		}
@@ -155,7 +161,7 @@ func (l *linterRunner) compileRegexes() error {
 		if err != nil {
 			return fmt.Errorf("incorrect 'allow disable' regex: %v", err)
 		}
-		l.allowDisableRegex = allowDisableRegex
+		l.config.AllowDisable = allowDisableRegex
 	}
 
 	switch l.args.unusedVarPattern {
@@ -165,7 +171,7 @@ func (l *linterRunner) compileRegexes() error {
 	case "^_.*$":
 		// Leading underscore plus anything after it.
 		// Recognize as quite common pattern.
-		linter.IsDiscardVar = func(s string) bool {
+		l.config.IsDiscardVar = func(s string) bool {
 			return strings.HasPrefix(s, "_")
 		}
 	default:
@@ -173,7 +179,7 @@ func (l *linterRunner) compileRegexes() error {
 		if err != nil {
 			return fmt.Errorf("incorrect unused-var-regex regex: %v", err)
 		}
-		linter.IsDiscardVar = re.MatchString
+		l.config.IsDiscardVar = re.MatchString
 	}
 
 	return nil
@@ -200,31 +206,31 @@ func (l *linterRunner) initRules(ruleSets []*rules.Set) error {
 		return l.IsEnabledByFlags(r.Name)
 	}
 
-	linter.Rules = rules.NewSet()
+	l.config.Rules = rules.NewSet()
 	for _, rset := range ruleSets {
-		appendRuleSet(rset, ruleFilter)
+		appendRuleSet(l.config, rset, ruleFilter)
 	}
 
 	return nil
 }
 
-func LoadMisspellDicts(dicts []string) error {
-	linter.TypoFixer = &misspell.Replacer{}
+func LoadMisspellDicts(config *linter.Config, dicts []string) error {
+	config.TypoFixer = &misspell.Replacer{}
 
 	for _, d := range dicts {
 		d = strings.TrimSpace(d)
 		switch {
 		case d == "Eng":
-			linter.TypoFixer.AddRuleList(misspell.DictMain)
+			config.TypoFixer.AddRuleList(misspell.DictMain)
 		case d == "Eng/US":
-			linter.TypoFixer.AddRuleList(misspell.DictAmerican)
+			config.TypoFixer.AddRuleList(misspell.DictAmerican)
 		case d == "Eng/UK" || d == "Eng/GB":
-			linter.TypoFixer.AddRuleList(misspell.DictBritish)
+			config.TypoFixer.AddRuleList(misspell.DictBritish)
 		default:
 			return fmt.Errorf("unsupported %s misspell-list entry", d)
 		}
 	}
 
-	linter.TypoFixer.Compile()
+	config.TypoFixer.Compile()
 	return nil
 }
diff --git a/src/cmd/main.go b/src/cmd/main.go
index 90e17ef..e7c4449 100644
--- a/src/cmd/main.go
+++ b/src/cmd/main.go
@@ -86,12 +86,12 @@ func isEnabled(l *linterRunner, r *linter.Report) bool {
 		return false
 	}
 
-	if linter.ExcludeRegex == nil {
+	if l.config.ExcludeRegex == nil {
 		return true
 	}
 
 	// Disabled by a file comment.
-	return !linter.ExcludeRegex.MatchString(r.Filename)
+	return !l.config.ExcludeRegex.MatchString(r.Filename)
 }
 
 // Run executes linter main function.
@@ -153,7 +153,7 @@ func Main(cfg *MainConfig) {
 // Note that if error is not nil, integer code will be discarded, so it can be 0.
 //
 // We don't want os.Exit to be inserted randomly to avoid defer cancellation.
-func mainNoExit(ruleSets []*rules.Set, args *cmdlineArguments, cfg *MainConfig) (int, error) {
+func mainNoExit(config *linter.Config, ruleSets []*rules.Set, args *cmdlineArguments, cfg *MainConfig) (int, error) {
 	if args.version {
 		// Version is already printed. Can exit here.
 		return 0, nil
@@ -198,17 +198,23 @@ func mainNoExit(ruleSets []*rules.Set, args *cmdlineArguments, cfg *MainConfig)
 
 	workspace.PHPExtensions = strings.Split(args.phpExtensionsArg, ",")
 
-	var l linterRunner
+	l := linterRunner{
+		config: config,
+	}
 	if err := l.Init(ruleSets, args); err != nil {
 		return 1, fmt.Errorf("init: %v", err)
 	}
 
-	lintdebug.Register(func(msg string) { linter.DebugMessage("%s", msg) })
-	go linter.MemoryLimiterThread()
+	lintdebug.Register(func(msg string) {
+		if config.Debug {
+			log.Print(msg)
+		}
+	})
+	go linter.MemoryLimiterThread(args.maxFileSize)
 
 	log.Printf("Started")
 
-	if err := initStubs(); err != nil {
+	if err := initStubs(l.config, l.getLinter()); err != nil {
 		return 0, fmt.Errorf("Init stubs: %v", err)
 	}
 
@@ -217,7 +223,7 @@ func mainNoExit(ruleSets []*rules.Set, args *cmdlineArguments, cfg *MainConfig)
 	}
 
 	log.Printf("Indexing %+v", flag.Args())
-	linter.ParseFilenames(workspace.ReadFilenames(flag.Args(), nil), l.allowDisableRegex)
+	l.getLinter().AnalyzeFiles(workspace.ReadFilenames(flag.Args(), nil))
 	parseIndexOnlyFiles(&l)
 	meta.SetIndexingComplete(true)
 
@@ -227,7 +233,7 @@ func mainNoExit(ruleSets []*rules.Set, args *cmdlineArguments, cfg *MainConfig)
 	}
 
 	log.Printf("Linting")
-	reports := linter.ParseFilenames(workspace.ReadFilenames(filenames, l.filenameFilter), l.allowDisableRegex)
+	reports := l.getLinter().AnalyzeFiles(workspace.ReadFilenames(filenames, l.filenameFilter))
 	if args.outputBaseline {
 		if err := createBaseline(&l, cfg, reports); err != nil {
 			return 1, fmt.Errorf("write baseline: %v", err)
@@ -386,21 +392,21 @@ func analyzeReports(l *linterRunner, cfg *MainConfig, diff []*linter.Report) (cr
 	return criticalReports, containsAutofixableReports
 }
 
-func initStubs() error {
-	if linter.StubsDir != "" {
-		linter.InitStubsFromDir(linter.StubsDir)
+func initStubs(config *linter.Config, l *linter.Linter) error {
+	if config.StubsDir != "" {
+		l.InitStubsFromDir(config.StubsDir)
 		return nil
 	}
 
 	// Try to use embedded stubs (from stubs/phpstorm_stubs.go).
-	if err := loadEmbeddedStubs(); err != nil {
+	if err := loadEmbeddedStubs(config); err != nil {
 		return fmt.Errorf("failed to load embedded stubs: %v", err)
 	}
 
 	return nil
 }
 
-func LoadEmbeddedStubs(filenames []string) error {
+func LoadEmbeddedStubs(config *linter.Config, filenames []string) error {
 	var errorsCount int64
 
 	readStubs := func(ch chan workspace.FileInfo) {
@@ -418,7 +424,8 @@ func LoadEmbeddedStubs(filenames []string) error {
 		}
 	}
 
-	linter.InitStubs(readStubs)
+	l := linter.NewLinter(config)
+	l.InitStubs(readStubs)
 
 	// Using atomic here for consistency.
 	if atomic.LoadInt64(&errorsCount) != 0 {
@@ -428,7 +435,7 @@ func LoadEmbeddedStubs(filenames []string) error {
 	return nil
 }
 
-func loadEmbeddedStubs() error {
+func loadEmbeddedStubs(config *linter.Config) error {
 	var filenames []string
 	// NOVERIFYDEBUG_LOAD_STUBS is used in golden tests to specify
 	// the test dependencies that need to be loaded.
@@ -440,5 +447,5 @@ func loadEmbeddedStubs() error {
 	if len(filenames) == 0 {
 		return fmt.Errorf("empty file list")
 	}
-	return LoadEmbeddedStubs(filenames)
+	return LoadEmbeddedStubs(config, filenames)
 }
diff --git a/src/cmd/php-guru/dupcode/utils.go b/src/cmd/php-guru/dupcode/utils.go
index 3ed28ef..f0aca50 100644
--- a/src/cmd/php-guru/dupcode/utils.go
+++ b/src/cmd/php-guru/dupcode/utils.go
@@ -42,19 +42,21 @@ func hasModifier(list []*ir.Identifier, key string) bool {
 }
 
 func runIndexing(cacheDir string, targets []string, filter *workspace.FilenameFilter) error {
-	linter.CacheDir = cacheDir
+	config := linter.NewConfig()
+	config.CacheDir = cacheDir
 
 	// If we don't do this, the program will hang.
-	go linter.MemoryLimiterThread()
+	go linter.MemoryLimiterThread(0)
 
 	// Handle stubs.
 	filenames := stubs.AssetNames()
-	if err := cmd.LoadEmbeddedStubs(filenames); err != nil {
+	if err := cmd.LoadEmbeddedStubs(config, filenames); err != nil {
 		return err
 	}
 
 	// Handle workspace files.
-	linter.ParseFilenames(workspace.ReadFilenames(targets, filter), nil)
+	l := linter.NewLinter(config)
+	l.AnalyzeFiles(workspace.ReadFilenames(targets, filter))
 
 	meta.SetIndexingComplete(true)
 	return nil
diff --git a/src/cmd/rules.go b/src/cmd/rules.go
index 1e07b0d..a11090b 100644
--- a/src/cmd/rules.go
+++ b/src/cmd/rules.go
@@ -12,13 +12,13 @@ import (
 	"github.com/VKCOM/noverify/src/rules"
 )
 
-func InitEmbeddedRules(p *rules.Parser, filter func(r rules.Rule) bool) ([]*rules.Set, error) {
+func InitEmbeddedRules(config *linter.Config, p *rules.Parser, filter func(r rules.Rule) bool) ([]*rules.Set, error) {
 	ruleSets, err := parseEmbeddedRules(p)
 	if err != nil {
 		return nil, err
 	}
 	for _, rset := range ruleSets {
-		appendRuleSet(rset, filter)
+		appendRuleSet(config, rset, filter)
 	}
 	return ruleSets, nil
 }
@@ -85,7 +85,7 @@ func parseEmbeddedRules(p *rules.Parser) ([]*rules.Set, error) {
 	return ruleSets, nil
 }
 
-func appendRuleSet(rset *rules.Set, filter func(r rules.Rule) bool) {
+func appendRuleSet(config *linter.Config, rset *rules.Set, filter func(r rules.Rule) bool) {
 	appendRules := func(dst, src *rules.ScopedSet) {
 		for i, list := range &src.RulesByKind {
 			for _, r := range list {
@@ -96,8 +96,7 @@ func appendRuleSet(rset *rules.Set, filter func(r rules.Rule) bool) {
 			}
 		}
 	}
-
-	appendRules(linter.Rules.Any, rset.Any)
-	appendRules(linter.Rules.Root, rset.Root)
-	appendRules(linter.Rules.Local, rset.Local)
+	appendRules(config.Rules.Any, rset.Any)
+	appendRules(config.Rules.Root, rset.Root)
+	appendRules(config.Rules.Local, rset.Local)
 }
diff --git a/src/linter/block.go b/src/linter/block.go
index 582374e..69a48e7 100644
--- a/src/linter/block.go
+++ b/src/linter/block.go
@@ -901,7 +901,7 @@ func (b *blockWalker) handleForeach(s *ir.ForeachStmt) bool {
 	if !ok {
 		return false
 	}
-	if IsDiscardVar(key.Name) {
+	if b.r.config.IsDiscardVar(key.Name) {
 		return false
 	}
 
@@ -1109,7 +1109,7 @@ func (b *blockWalker) handleVariable(v ir.Node) bool {
 				w.untrackVarName(varName)
 			}
 		}
-		if IsDiscardVar(varName) && !isSuperGlobal(varName) {
+		if b.r.config.IsDiscardVar(varName) && !isSuperGlobal(varName) {
 			b.r.Report(v, LevelError, "discardVar", "Used var $%s that is supposed to be unused (rename variable if it's intended)", varName)
 		}
 
@@ -1733,7 +1733,7 @@ func (b *blockWalker) flushUnused() {
 
 	visitedMap := make(map[ir.Node]struct{})
 	for name, nodes := range b.unusedVars {
-		if IsDiscardVar(name) {
+		if b.r.config.IsDiscardVar(name) {
 			// blank identifier is a way to tell linter (and PHPStorm) that result is explicitly unused
 			continue
 		}
diff --git a/src/linter/block_linter.go b/src/linter/block_linter.go
index 74750c3..b7bf390 100644
--- a/src/linter/block_linter.go
+++ b/src/linter/block_linter.go
@@ -570,7 +570,7 @@ func (b *blockLinter) checkContinueStmt(c *ir.ContinueStmt) {
 }
 
 func (b *blockLinter) addFixForArray(arr *ir.ArrayExpr) {
-	if !ApplyQuickFixes {
+	if !b.walker.r.config.ApplyQuickFixes {
 		return
 	}
 
diff --git a/src/linter/conf.go b/src/linter/conf.go
index 38de7db..1ace87e 100644
--- a/src/linter/conf.go
+++ b/src/linter/conf.go
@@ -12,7 +12,7 @@ import (
 	"github.com/VKCOM/noverify/src/rules"
 )
 
-var (
+type Config struct {
 	// BaselineProfile is a suppression database for warnings.
 	// Nil profile is an empty suppression profile.
 	BaselineProfile      *baseline.Profile
@@ -31,30 +31,36 @@ var (
 	TypoFixer *misspell.Replacer
 
 	// SrcInput implements source code reading from files and buffers.
-	//
-	// TODO(quasilyte): avoid having it as a global variable?
-	SrcInput = inputs.NewDefaultSourceInput()
+	SrcInput inputs.SourceInput
 
 	// Rules is a set of dynamically loaded linter diagnostics.
-	Rules = &rules.Set{}
+	Rules *rules.Set
 
 	// settings
 
-	StubsDir       string
-	Debug          bool
-	MaxConcurrency = runtime.NumCPU()
-	MaxFileSize    int
+	StubsDir string
+	Debug    bool
+
+	// MaxConcurrency limits the linter concurrency.
+	MaxConcurrency int
 
 	// DebugParseDuration specifies the minimum parse duration for it to be printed to debug output.
 	DebugParseDuration time.Duration
 
 	CheckAutoGenerated bool
 
-	IsDiscardVar = isUnderscore
+	IsDiscardVar func(varname string) bool
 
 	ExcludeRegex *regexp.Regexp
 
-	// actually time.Duration
-	initParseTime int64
-	initWalkTime  int64
-)
+	AllowDisable *regexp.Regexp
+}
+
+func NewConfig() *Config {
+	return &Config{
+		SrcInput:       inputs.NewDefaultSourceInput(),
+		Rules:          &rules.Set{},
+		MaxConcurrency: runtime.NumCPU(),
+		IsDiscardVar:   isUnderscore,
+	}
+}
diff --git a/src/linter/debug.go b/src/linter/debug.go
deleted file mode 100644
index 9db87b3..0000000
--- a/src/linter/debug.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package linter
-
-import (
-	"log"
-)
-
-// DebugMessage is used to actually print debug messages.
-func DebugMessage(msg string, args ...interface{}) {
-	if Debug {
-		log.Printf(msg, args...)
-	}
-}
diff --git a/src/linter/limits.go b/src/linter/limits.go
index babf3ae..451c4ce 100644
--- a/src/linter/limits.go
+++ b/src/linter/limits.go
@@ -17,11 +17,15 @@ var (
 	parseFinishedCh = make(chan int)
 )
 
-// MemoryLimiterThread starts memory limiter goroutine that disallows to use parse files more than MaxFileSize
+// MemoryLimiterThread starts memory limiter goroutine that disallows to use parse files more than maxFileSize
 // total bytes.
-func MemoryLimiterThread() {
+func MemoryLimiterThread(maxFileSize int) {
 	var used int
 
+	if maxFileSize <= 0 {
+		maxFileSize = 20 * 1024 * 1024
+	}
+
 	plusCh := parseStartCh
 	minusCh := parseFinishedCh
 
@@ -29,13 +33,13 @@ func MemoryLimiterThread() {
 		select {
 		case req := <-plusCh:
 			used += req.size
-			if used > MaxFileSize {
+			if used > maxFileSize {
 				lintdebug.Send("Limiting concurrency to save memory: currently parsing %s, total file size %d KiB", req.filename, used/1024)
 				plusCh = nil
 			}
 		case sz := <-minusCh:
 			used -= sz
-			if used <= MaxFileSize {
+			if used <= maxFileSize {
 				plusCh = parseStartCh
 			}
 		}
diff --git a/src/linter/linter.go b/src/linter/linter.go
new file mode 100644
index 0000000..97037d5
--- /dev/null
+++ b/src/linter/linter.go
@@ -0,0 +1,106 @@
+package linter
+
+import (
+	"regexp"
+	"sync"
+	"time"
+
+	"github.com/VKCOM/noverify/src/lintdebug"
+	"github.com/VKCOM/noverify/src/meta"
+	"github.com/VKCOM/noverify/src/workspace"
+)
+
+type Linter struct {
+	config *Config
+}
+
+func NewLinter(config *Config) *Linter {
+	return &Linter{
+		config: config,
+	}
+}
+
+func (l *Linter) NewLintingWorker(id int) *Worker {
+	w := newWorker(l.config, id)
+	w.needReports = true
+	return w
+}
+
+func (l *Linter) NewIndexingWorker(id int) *Worker {
+	w := newWorker(l.config, id)
+	w.needReports = false
+	return w
+}
+
+// AnalyzeFiles runs linter on the files that are provided by the readFileNamesFunc function.
+func (l *Linter) AnalyzeFiles(readFileNamesFunc workspace.ReadCallback) []*Report {
+	return l.analyzeFiles(readFileNamesFunc, l.config.AllowDisable)
+}
+
+func (l *Linter) analyzeFiles(readFileNamesFunc workspace.ReadCallback, allowDisable *regexp.Regexp) []*Report {
+	start := time.Now()
+	defer func() {
+		lintdebug.Send("Processing time: %s", time.Since(start))
+
+		meta.Info.Lock()
+		defer meta.Info.Unlock()
+
+		lintdebug.Send("Funcs: %d, consts: %d, files: %d", meta.Info.NumFunctions(), meta.Info.NumConstants(), meta.Info.NumFilesWithFunctions())
+	}()
+
+	needReports := meta.IsIndexingComplete()
+
+	lintdebug.Send("Parsing using %d cores", l.config.MaxConcurrency)
+
+	filenamesCh := make(chan workspace.FileInfo, 512)
+
+	go func() {
+		readFileNamesFunc(filenamesCh)
+		close(filenamesCh)
+	}()
+
+	var wg sync.WaitGroup
+	reportsCh := make(chan []*Report, l.config.MaxConcurrency)
+
+	wg.Add(l.config.MaxConcurrency)
+	for i := 0; i < l.config.MaxConcurrency; i++ {
+		go func(id int) {
+			var w *Worker
+			if needReports {
+				w = l.NewLintingWorker(id)
+			} else {
+				w = l.NewIndexingWorker(id)
+			}
+			w.AllowDisable = allowDisable
+			var rep []*Report
+			for f := range filenamesCh {
+				rep = append(rep, w.doParseFile(f)...)
+			}
+			reportsCh <- rep
+			wg.Done()
+		}(i)
+	}
+	wg.Wait()
+
+	var allReports []*Report
+	for i := 0; i < l.config.MaxConcurrency; i++ {
+		allReports = append(allReports, <-reportsCh...)
+	}
+
+	return allReports
+}
+
+func (l *Linter) InitStubs(readFileNamesFunc workspace.ReadCallback) {
+	meta.SetLoadingStubs(true)
+	l.analyzeFiles(readFileNamesFunc, nil)
+	meta.Info.InitStubs()
+	if l.config.KPHP {
+		meta.Info.InitKphpStubs()
+	}
+	meta.SetLoadingStubs(false)
+}
+
+// InitStubsFromDir parses directory with PHPStorm stubs which has all internal PHP classes and functions declared.
+func (l *Linter) InitStubsFromDir(dir string) {
+	l.InitStubs(workspace.ReadFilenames([]string{dir}, nil))
+}
diff --git a/src/linter/parser.go b/src/linter/parser.go
deleted file mode 100644
index 153e98d..0000000
--- a/src/linter/parser.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package linter
-
-import (
-	"regexp"
-	"strings"
-	"sync"
-	"time"
-
-	"github.com/VKCOM/noverify/src/lintdebug"
-	"github.com/VKCOM/noverify/src/meta"
-	"github.com/VKCOM/noverify/src/rules"
-	"github.com/VKCOM/noverify/src/workspace"
-)
-
-func cloneRulesForFile(filename string, ruleSet *rules.ScopedSet) *rules.ScopedSet {
-	if ruleSet == nil {
-		return nil
-	}
-
-	var clone rules.ScopedSet
-	for i, list := range &ruleSet.RulesByKind {
-		res := make([]rules.Rule, 0, len(list))
-		for _, rule := range list {
-			if !strings.Contains(filename, rule.Path) {
-				continue
-			}
-			res = append(res, rule)
-		}
-		clone.RulesByKind[i] = res
-	}
-	return &clone
-}
-
-// ParseFilenames is used to do initial parsing of files.
-func ParseFilenames(readFileNamesFunc workspace.ReadCallback, allowDisabled *regexp.Regexp) []*Report {
-	start := time.Now()
-	defer func() {
-		lintdebug.Send("Processing time: %s", time.Since(start))
-
-		meta.Info.Lock()
-		defer meta.Info.Unlock()
-
-		lintdebug.Send("Funcs: %d, consts: %d, files: %d", meta.Info.NumFunctions(), meta.Info.NumConstants(), meta.Info.NumFilesWithFunctions())
-	}()
-
-	needReports := meta.IsIndexingComplete()
-
-	lintdebug.Send("Parsing using %d cores", MaxConcurrency)
-
-	filenamesCh := make(chan workspace.FileInfo, 512)
-
-	go func() {
-		readFileNamesFunc(filenamesCh)
-		close(filenamesCh)
-	}()
-
-	var wg sync.WaitGroup
-	reportsCh := make(chan []*Report, MaxConcurrency)
-
-	wg.Add(MaxConcurrency)
-	for i := 0; i < MaxConcurrency; i++ {
-		go func(id int) {
-			var w *Worker
-			if needReports {
-				w = NewLintingWorker(id)
-			} else {
-				w = NewIndexingWorker(id)
-			}
-			w.AllowDisable = allowDisabled
-			var rep []*Report
-			for f := range filenamesCh {
-				rep = append(rep, w.doParseFile(f)...)
-			}
-			reportsCh <- rep
-			wg.Done()
-		}(i)
-	}
-	wg.Wait()
-
-	var allReports []*Report
-	for i := 0; i < MaxConcurrency; i++ {
-		allReports = append(allReports, <-reportsCh...)
-	}
-
-	return allReports
-}
-
-func InitStubs(readFileNamesFunc workspace.ReadCallback) {
-	meta.SetLoadingStubs(true)
-	ParseFilenames(readFileNamesFunc, nil)
-	meta.Info.InitStubs()
-	if KPHP {
-		meta.Info.InitKphpStubs()
-	}
-	meta.SetLoadingStubs(false)
-}
-
-// InitStubsFromDir parses directory with PHPStorm stubs which has all internal PHP classes and functions declared.
-func InitStubsFromDir(dir string) {
-	InitStubs(workspace.ReadFilenames([]string{dir}, nil))
-}
diff --git a/src/linter/root.go b/src/linter/root.go
index 732fef7..729b50e 100644
--- a/src/linter/root.go
+++ b/src/linter/root.go
@@ -63,6 +63,8 @@ type rootWalker struct {
 	strictTypes bool
 
 	reports []*Report
+
+	config *Config
 }
 
 type phpDocParamEl struct {
@@ -282,7 +284,7 @@ func (d *rootWalker) report(n ir.Node, lineNumber int, level int, checkName, msg
 	if !meta.IsIndexingComplete() {
 		return
 	}
-	if d.file.AutoGenerated() && !CheckAutoGenerated {
+	if d.file.AutoGenerated() && !d.config.CheckAutoGenerated {
 		return
 	}
 	// We don't report anything if linter was disabled by a
@@ -362,7 +364,7 @@ func (d *rootWalker) report(n ir.Node, lineNumber int, level int, checkName, msg
 	}
 	msg = fmt.Sprintf(msg, args...)
 	var hash uint64
-	if BaselineProfile != nil {
+	if d.config.BaselineProfile != nil {
 		// If baseline is not enabled, don't waste time on hash computations.
 		hash = d.reportHash(&pos, startLn, checkName, msg)
 		if count := d.ctx.baseline.Count(hash); count >= 1 {
@@ -421,7 +423,7 @@ func (d *rootWalker) reportHash(pos *position.Position, startLine []byte, checkN
 	var prevLine []byte
 	var nextLine []byte
 	// Adjacent lines are only included when using non-conservative baseline.
-	if !ConservativeBaseline {
+	if !d.config.ConservativeBaseline {
 		// Lines are 1-based, indexes are 0-based.
 		// If this function is called, we expect that lines[index] exists.
 		index := pos.StartLine - 1
@@ -587,7 +589,7 @@ func (d *rootWalker) handleFuncStmts(params []meta.FuncParam, uses, stmts []ir.N
 		if p.IsRef {
 			b.nonLocalVars[p.Name] = varRef
 		}
-		if !p.IsRef && !IsDiscardVar(p.Name) {
+		if !p.IsRef && !d.config.IsDiscardVar(p.Name) {
 			b.unusedParams[p.Name] = struct{}{}
 		}
 	}
@@ -1468,10 +1470,10 @@ func (d *rootWalker) checkMisspellings(n ir.Node, s string, label string, skip f
 	if !meta.IsIndexingComplete() {
 		return
 	}
-	if TypoFixer == nil {
+	if d.config.TypoFixer == nil {
 		return
 	}
-	_, changes := TypoFixer.Replace(s)
+	_, changes := d.config.TypoFixer.Replace(s)
 	for _, c := range changes {
 		if skip(c.Corrected) || skip(c.Original) {
 			continue
@@ -1793,7 +1795,7 @@ func (d *rootWalker) runRule(n ir.Node, sc *meta.Scope, rule *rules.Rule) bool {
 	message := d.renderRuleMessage(rule.Message, n, m, true)
 	d.Report(location, rule.Level, rule.Name, "%s", message)
 
-	if ApplyQuickFixes && rule.Fix != "" {
+	if d.config.ApplyQuickFixes && rule.Fix != "" {
 		// As rule sets contain only enabled rules,
 		// we should be OK without any filtering here.
 		pos := ir.GetPosition(n)
diff --git a/src/linter/root_context.go b/src/linter/root_context.go
index 5e94be8..88191d5 100644
--- a/src/linter/root_context.go
+++ b/src/linter/root_context.go
@@ -25,16 +25,16 @@ type rootContext struct {
 	fixes []quickfix.TextEdit
 }
 
-func newRootContext(workerCtx *WorkerContext, st *meta.ClassParseState) rootContext {
+func newRootContext(config *Config, workerCtx *WorkerContext, st *meta.ClassParseState) rootContext {
 	var p baseline.FileProfile
-	if BaselineProfile != nil {
+	if config.BaselineProfile != nil {
 		filename := filepath.Base(st.CurrentFile)
-		p = BaselineProfile.Files[filename]
+		p = config.BaselineProfile.Files[filename]
 	}
 	return rootContext{
 		WorkerContext: workerCtx,
 
-		typeNormalizer: typeNormalizer{st: st},
+		typeNormalizer: typeNormalizer{st: st, kphp: config.KPHP},
 		st:             st,
 		baseline:       p,
 	}
diff --git a/src/linter/types.go b/src/linter/types.go
index 733935d..24a1c42 100644
--- a/src/linter/types.go
+++ b/src/linter/types.go
@@ -248,7 +248,8 @@ func typesFromNode(typeNode ir.Node) []meta.Type {
 }
 
 type typeNormalizer struct {
-	st *meta.ClassParseState
+	st   *meta.ClassParseState
+	kphp bool
 }
 
 func (n typeNormalizer) NormalizeTypes(types []meta.Type) {
@@ -273,7 +274,7 @@ func (n typeNormalizer) normalizeType(typ *meta.Type) {
 		return
 	}
 
-	if typ.Elem == "any" && KPHP {
+	if typ.Elem == "any" && n.kphp {
 		// `any` is a special KPHP type that is more-or-less
 		// identical to `mixed|object`. In PHP, `mixed` already covers
 		// objects, so there is no need to add `object`.
diff --git a/src/linter/utils.go b/src/linter/utils.go
index 3783fd6..72d24d7 100644
--- a/src/linter/utils.go
+++ b/src/linter/utils.go
@@ -10,6 +10,7 @@ import (
 	"github.com/VKCOM/noverify/src/ir"
 	"github.com/VKCOM/noverify/src/meta"
 	"github.com/VKCOM/noverify/src/phpdoc"
+	"github.com/VKCOM/noverify/src/rules"
 	"github.com/VKCOM/noverify/src/solver"
 )
 
@@ -518,6 +519,25 @@ func binaryOpString(n ir.Node) string {
 	}
 }
 
+func cloneRulesForFile(filename string, ruleSet *rules.ScopedSet) *rules.ScopedSet {
+	if ruleSet == nil {
+		return nil
+	}
+
+	var clone rules.ScopedSet
+	for i, list := range &ruleSet.RulesByKind {
+		res := make([]rules.Rule, 0, len(list))
+		for _, rule := range list {
+			if !strings.Contains(filename, rule.Path) {
+				continue
+			}
+			res = append(res, rule)
+		}
+		clone.RulesByKind[i] = res
+	}
+	return &clone
+}
+
 // List taken from https://wiki.php.net/rfc/context_sensitive_lexer
 var phpKeywords = map[string]bool{
 	"callable":     true,
diff --git a/src/linter/worker.go b/src/linter/worker.go
index d9e0a19..3818c04 100644
--- a/src/linter/worker.go
+++ b/src/linter/worker.go
@@ -50,24 +50,15 @@ type Worker struct {
 	needReports bool
 
 	AllowDisable *regexp.Regexp
-}
-
-func NewLintingWorker(id int) *Worker {
-	w := newWorker(id)
-	w.needReports = true
-	return w
-}
 
-func NewIndexingWorker(id int) *Worker {
-	w := newWorker(id)
-	w.needReports = false
-	return w
+	config *Config
 }
 
-func newWorker(id int) *Worker {
+func newWorker(config *Config, id int) *Worker {
 	ctx := NewWorkerContext()
 	irConverter := irconv.NewConverter(ctx.phpdocTypeParser)
 	return &Worker{
+		config: config,
 		id:     id,
 		ctx:    ctx,
 		irconv: irConverter,
@@ -93,16 +84,14 @@ func (w *Worker) ParseContents(fileInfo workspace.FileInfo) (result ParseResult,
 		}
 	}()
 
-	start := time.Now()
-
 	// TODO: Ragel lexer can handle non-UTF8 input.
 	// We can simplify code below and read from files directly.
 
 	var rd inputs.ReadCloseSizer
 	if fileInfo.Contents == nil {
-		rd, err = SrcInput.NewReader(fileInfo.Name)
+		rd, err = w.config.SrcInput.NewReader(fileInfo.Name)
 	} else {
-		rd, err = SrcInput.NewBytesReader(fileInfo.Name, fileInfo.Contents)
+		rd, err = w.config.SrcInput.NewBytesReader(fileInfo.Name, fileInfo.Contents)
 	}
 	if err != nil {
 		log.Panicf("open source input: %v", err)
@@ -123,8 +112,6 @@ func (w *Worker) ParseContents(fileInfo workspace.FileInfo) (result ParseResult,
 	parser.WithFreeFloating()
 	parser.Parse()
 
-	atomic.AddInt64(&initParseTime, int64(time.Since(start)))
-
 	file := workspace.NewFile(fileInfo.Name, contents)
 	rootNode, walker, err := w.analyzeFile(file, parser)
 	if err != nil {
@@ -140,7 +127,7 @@ func (w *Worker) ParseContents(fileInfo workspace.FileInfo) (result ParseResult,
 
 // IndexFile parses the file and fills in the meta info. Can use cache.
 func (w *Worker) IndexFile(file workspace.FileInfo) error {
-	if CacheDir == "" {
+	if w.config.CacheDir == "" {
 		result, err := w.ParseContents(file)
 		if w != nil {
 			updateMetaInfo(file.Name, &result.walker.meta)
@@ -178,7 +165,7 @@ func (w *Worker) IndexFile(file workspace.FileInfo) error {
 		cacheFilenamePart = file.Name[0:1] + "_" + file.Name[2:]
 	}
 
-	cacheFile := filepath.Join(CacheDir, cacheFilenamePart+"."+contentsHash)
+	cacheFile := filepath.Join(w.config.CacheDir, cacheFilenamePart+"."+contentsHash)
 
 	start := time.Now()
 	fp, err := os.Open(cacheFile)
@@ -211,10 +198,10 @@ func (w *Worker) IndexFile(file workspace.FileInfo) error {
 func (w *Worker) doParseFile(f workspace.FileInfo) []*Report {
 	var err error
 
-	if DebugParseDuration > 0 {
+	if w.config.DebugParseDuration > 0 {
 		start := time.Now()
 		defer func() {
-			if dur := time.Since(start); dur > DebugParseDuration {
+			if dur := time.Since(start); dur > w.config.DebugParseDuration {
 				log.Printf("Parsing of %s took %s", f.Name, dur)
 			}
 		}()
@@ -241,7 +228,6 @@ func (w *Worker) doParseFile(f workspace.FileInfo) []*Report {
 }
 
 func (w *Worker) analyzeFile(file *workspace.File, parser *php7.Parser) (*ir.Root, *rootWalker, error) {
-	start := time.Now()
 	rootNode := parser.GetRootNode()
 
 	if rootNode == nil {
@@ -253,14 +239,15 @@ func (w *Worker) analyzeFile(file *workspace.File, parser *php7.Parser) (*ir.Roo
 
 	st := &meta.ClassParseState{CurrentFile: file.Name()}
 	walker := &rootWalker{
-		file: file,
-		ctx:  newRootContext(w.ctx, st),
+		config: w.config,
+		file:   file,
+		ctx:    newRootContext(w.config, w.ctx, st),
 
 		// We clone rules sets to remove all rules that
 		// should not be applied to this file because of the @path.
-		anyRset:   cloneRulesForFile(file.Name(), Rules.Any),
-		rootRset:  cloneRulesForFile(file.Name(), Rules.Root),
-		localRset: cloneRulesForFile(file.Name(), Rules.Local),
+		anyRset:   cloneRulesForFile(file.Name(), w.config.Rules.Any),
+		rootRset:  cloneRulesForFile(file.Name(), w.config.Rules.Root),
+		localRset: cloneRulesForFile(file.Name(), w.config.Rules.Local),
 
 		reVet: &regexpVet{
 			parser: w.reParser,
@@ -292,8 +279,6 @@ func (w *Worker) analyzeFile(file *workspace.File, parser *php7.Parser) (*ir.Roo
 		walker.Report(nil, LevelError, "syntax", "Syntax error: "+e.String())
 	}
 
-	atomic.AddInt64(&initWalkTime, int64(time.Since(start)))
-
 	return rootIR, walker, nil
 }
 


Test patch:
diff --git a/src/ir/normalize/normalize_test.go b/src/ir/normalize/normalize_test.go
index c4e65a2..22479eb 100644
--- a/src/ir/normalize/normalize_test.go
+++ b/src/ir/normalize/normalize_test.go
@@ -8,6 +8,7 @@ import (
 	"github.com/VKCOM/noverify/src/ir"
 	"github.com/VKCOM/noverify/src/ir/irconv"
 	"github.com/VKCOM/noverify/src/ir/irfmt"
+	"github.com/VKCOM/noverify/src/linter"
 	"github.com/VKCOM/noverify/src/linttest"
 	"github.com/VKCOM/noverify/src/meta"
 	"github.com/VKCOM/noverify/src/php/parser/php7"
@@ -210,7 +211,8 @@ func TestNormalizeExpr(t *testing.T) {
 }
 
 func TestNormalizeExprAfterIndexing(t *testing.T) {
-	linttest.ParseTestFile(t, "defs.php", `<?php
+	l := linter.NewLinter(linter.NewConfig())
+	linttest.ParseTestFile(t, l, "defs.php", `<?php
 const ZERO = 0;
 const HELLO_WORLD = 'hello, world';
 const LOCALHOST = "127.0.0.1";
@@ -231,7 +233,8 @@ class Foo {
 }
 
 func TestMagicConstFold(t *testing.T) {
-	linttest.ParseTestFile(t, "files/file.php", `<?php
+	l := linter.NewLinter(linter.NewConfig())
+	linttest.ParseTestFile(t, l, "files/file.php", `<?php
 namespace Boo;
 
 const NAMESPACE_NAME = __NAMESPACE__;
diff --git a/src/linter/cache_test.go b/src/linter/cache_test.go
index c2e9493..ad3f77d 100644
--- a/src/linter/cache_test.go
+++ b/src/linter/cache_test.go
@@ -19,7 +19,7 @@ import (
 )
 
 func TestCache(t *testing.T) {
-	go MemoryLimiterThread()
+	go MemoryLimiterThread(0)
 
 	// If this test is failing, you haven't broken anything (unless the decoding is failing),
 	// but meta cache probably needs to be invalidated.
@@ -118,8 +118,9 @@ class ByNull {
 main();
 `
 
+	l := NewLinter(NewConfig())
 	runTest := func(iteration int) {
-		result, err := parseContents("cachetest.php", []byte(code), nil, nil)
+		result, err := parseContents(l, "cachetest.php", []byte(code), nil)
 		if err != nil {
 			t.Fatalf("parse error: %v", err)
 		}
@@ -191,9 +192,9 @@ func collectCacheStrings(data string) string {
 	return hex.EncodeToString(enc.Sum(nil))
 }
 
-func parseContents(filename string, contents []byte, lineRanges []git.LineRange, allowDisabled *regexp.Regexp) (ParseResult, error) {
-	w := NewLintingWorker(0)
-	w.AllowDisable = allowDisabled
+func parseContents(l *Linter, filename string, contents []byte, lineRanges []git.LineRange) (ParseResult, error) {
+	w := l.NewLintingWorker(0)
+	w.AllowDisable = l.config.AllowDisable
 	file := workspace.FileInfo{
 		Name:       filename,
 		Contents:   contents,
diff --git a/src/linter/phpdoc_test.go b/src/linter/phpdoc_test.go
index 1fca2ee..ef3f81e 100644
--- a/src/linter/phpdoc_test.go
+++ b/src/linter/phpdoc_test.go
@@ -58,8 +58,9 @@ func TestParseClassPHPDoc(t *testing.T) {
 		},
 	}
 
+	l := NewLinter(NewConfig())
 	st := &meta.ClassParseState{}
-	walker := rootWalker{ctx: newRootContext(NewWorkerContext(), st)}
+	walker := rootWalker{ctx: newRootContext(l.config, NewWorkerContext(), st)}
 	for _, test := range tests {
 		docString := fmt.Sprintf(`/** %s */`, test.line)
 		doc := phpdoc.Parse(walker.ctx.phpdocTypeParser, docString)
@@ -110,8 +111,9 @@ func TestParseClassPHPDoc(t *testing.T) {
 }
 
 func BenchmarkParseTypes(b *testing.B) {
+	l := NewLinter(NewConfig())
 	st := &meta.ClassParseState{}
-	ctx := newRootContext(NewWorkerContext(), st)
+	ctx := newRootContext(l.config, NewWorkerContext(), st)
 	typeString := `?x|array<int>|T[]`
 	b.ResetTimer()
 	for i := 0; i < b.N; i++ {
diff --git a/src/linttest/golden_linttest.go b/src/linttest/golden_linttest.go
index 6adf50b..e4bd9a1 100644
--- a/src/linttest/golden_linttest.go
+++ b/src/linttest/golden_linttest.go
@@ -75,8 +75,11 @@ func NewGoldenTestSuite(t *testing.T, name, baseDir, goldenFileName string) *Gol
 // PrepareGoldenTestSuite configures fields and standard stubs.
 //
 // Used if the structure was created directly.
-func PrepareGoldenTestSuite(s *GoldenTestSuite, t *testing.T, baseDir, goldenFileName string) {
+func PrepareGoldenTestSuite(s *GoldenTestSuite, t *testing.T, config *linter.Config, baseDir, goldenFileName string) {
 	s.suite = NewSuite(t)
+	if config != nil {
+		s.suite.Config = config
+	}
 	s.BaseDir = baseDir
 	s.prepared = true
 	s.GoldenFileName = goldenFileName
diff --git a/src/linttest/linttest.go b/src/linttest/linttest.go
index 405fcbf..744d863 100644
--- a/src/linttest/linttest.go
+++ b/src/linttest/linttest.go
@@ -87,6 +87,8 @@ type Suite struct {
 	MisspellList string
 
 	IgnoreUndeclaredChecks bool
+
+	Config *linter.Config
 }
 
 // NewSuite returns a new linter test suite for t.
@@ -98,6 +100,7 @@ func NewSuite(t testing.TB) *Suite {
 			`stubs/phpstorm-stubs/Core/Core_c.php`: {},
 			`stubs/phpstorm-stubs/Core/Core_d.php`: {},
 		},
+		Config: linter.NewConfig(),
 	}
 }
 
@@ -209,6 +212,8 @@ func (s *Suite) RunLinter() []*linter.Report {
 	s.t.Helper()
 	meta.ResetInfo()
 
+	l := linter.NewLinter(s.Config)
+
 	for _, stub := range s.LoadStubs {
 		s.defaultStubs[stub] = struct{}{}
 	}
@@ -216,18 +221,18 @@ func (s *Suite) RunLinter() []*linter.Report {
 	for stub := range s.defaultStubs {
 		stubs = append(stubs, stub)
 	}
-	if err := cmd.LoadEmbeddedStubs(stubs); err != nil {
+	if err := cmd.LoadEmbeddedStubs(s.Config, stubs); err != nil {
 		s.t.Fatalf("load stubs: %v", err)
 	}
 
 	if s.MisspellList != "" {
-		err := cmd.LoadMisspellDicts(strings.Split(s.MisspellList, ","))
+		err := cmd.LoadMisspellDicts(s.Config, strings.Split(s.MisspellList, ","))
 		if err != nil {
 			s.t.Fatalf("load misspell dicts: %v", err)
 		}
 	}
 
-	indexing := linter.NewIndexingWorker(0)
+	indexing := l.NewIndexingWorker(0)
 	indexing.AllowDisable = s.AllowDisable
 
 	shuffleFiles(s.Files)
@@ -237,7 +242,7 @@ func (s *Suite) RunLinter() []*linter.Report {
 
 	meta.SetIndexingComplete(true)
 
-	linting := linter.NewLintingWorker(0)
+	linting := l.NewLintingWorker(0)
 	linting.AllowDisable = s.AllowDisable
 
 	shuffleFiles(s.Files)
@@ -289,13 +294,12 @@ func (s *Suite) RunFilterLinter(filters []string) []*linter.Report {
 	return filteredReports
 }
 
-// ParseTestFile parses given test file.
-func ParseTestFile(t *testing.T, filename, content string) linter.ParseResult {
+func ParseTestFile(t *testing.T, l *linter.Linter, filename, content string) linter.ParseResult {
 	var worker *linter.Worker
 	if meta.IsIndexingComplete() {
-		worker = linter.NewLintingWorker(0)
+		worker = l.NewLintingWorker(0)
 	} else {
-		worker = linter.NewIndexingWorker(0)
+		worker = l.NewIndexingWorker(0)
 	}
 	return parseTestFile(t, worker, TestFile{
 		Name: filename,
@@ -325,11 +329,11 @@ func FindPHPFiles(root string) ([]string, error) {
 }
 
 // InitEmbeddedRules initializes embedded rules for testing.
-func InitEmbeddedRules() error {
+func InitEmbeddedRules(config *linter.Config) error {
 	enableAllRules := func(_ rules.Rule) bool { return true }
 	p := rules.NewParser()
-	linter.Rules = rules.NewSet()
-	ruleSets, err := cmd.InitEmbeddedRules(p, enableAllRules)
+	config.Rules = rules.NewSet()
+	ruleSets, err := cmd.InitEmbeddedRules(config, p, enableAllRules)
 	if err != nil {
 		return fmt.Errorf("init embedded rules: %v", err)
 	}
@@ -356,7 +360,7 @@ func filterReports(names []string, reports []*linter.Report) []*linter.Report {
 
 func init() {
 	var once sync.Once
-	once.Do(func() { go linter.MemoryLimiterThread() })
+	once.Do(func() { go linter.MemoryLimiterThread(0) })
 }
 
 func shuffleFiles(files []TestFile) {
diff --git a/src/tests/checkers/basic_test.go b/src/tests/checkers/basic_test.go
index 4424d54..d5902dd 100644
--- a/src/tests/checkers/basic_test.go
+++ b/src/tests/checkers/basic_test.go
@@ -787,15 +787,13 @@ for ($i = 0; $i == 0; $i = $i++) {}
 }
 
 func TestCustomUnusedVarRegex(t *testing.T) {
-	defer func(isDiscardVar func(string) bool) {
-		linter.IsDiscardVar = isDiscardVar
-	}(linter.IsDiscardVar)
-
-	linter.IsDiscardVar = func(s string) bool {
+	isDiscardVar := func(s string) bool {
 		return strings.HasPrefix(s, "_")
 	}
 
-	linttest.SimpleNegativeTest(t, `<?php
+	test := linttest.NewSuite(t)
+	test.Config.IsDiscardVar = isDiscardVar
+	test.AddFile(`<?php
 class Foo {
   public $_;
   private $_foo;
@@ -807,8 +805,11 @@ class Foo {
 }
 $_ = __FILE__;
 `)
+	test.RunAndMatch()
 
-	linttest.SimpleNegativeTest(t, `<?php
+	test = linttest.NewSuite(t)
+	test.Config.IsDiscardVar = isDiscardVar
+	test.AddFile(`<?php
 $_unused = 10;
 
 function f() {
@@ -822,7 +823,8 @@ function f() {
 }
 `)
 
-	test := linttest.NewSuite(t)
+	test = linttest.NewSuite(t)
+	test.Config.IsDiscardVar = isDiscardVar
 	test.AddFile(`<?php
 function var_dump($v) {}
 $_global = 120;
diff --git a/src/tests/checkers/kphp_test.go b/src/tests/checkers/kphp_test.go
index 165b273..2d62251 100644
--- a/src/tests/checkers/kphp_test.go
+++ b/src/tests/checkers/kphp_test.go
@@ -3,18 +3,18 @@ package checkers_test
 import (
 	"testing"
 
-	"github.com/VKCOM/noverify/src/linter"
 	"github.com/VKCOM/noverify/src/linttest"
 )
 
 func TestArrayAccessForAny(t *testing.T) {
-	linter.KPHP = true
-	linttest.SimpleNegativeTest(t, `<?php
+	test := linttest.NewSuite(t)
+	test.Config.KPHP = true
+	test.AddFile(`<?php
 	/** @return any */
 	function get_any() {
 		return [];
 	}
 	$any = get_any();
 	$_ = $any[0];`)
-	linter.KPHP = false
+	test.RunAndMatch()
 }
diff --git a/src/tests/checkers/misspell_test.go b/src/tests/checkers/misspell_test.go
index 22f7184..abd5126 100644
--- a/src/tests/checkers/misspell_test.go
+++ b/src/tests/checkers/misspell_test.go
@@ -5,17 +5,13 @@ import (
 
 	"github.com/client9/misspell"
 
-	"github.com/VKCOM/noverify/src/linter"
 	"github.com/VKCOM/noverify/src/linttest"
 )
 
-func init() {
-	linter.TypoFixer = misspell.New()
-}
-
 //nolint:misspell // misspelled on purpose
 func TestMisspellPhpdocPositive(t *testing.T) {
 	test := linttest.NewSuite(t)
+	test.Config.TypoFixer = misspell.New()
 	test.AddFile(`<?php
 /**
  * This function is a pure perfektion.
@@ -54,7 +50,9 @@ class c1 {
 
 //nolint:misspell // misspelled on purpose
 func TestMisspellPhpdocNegative(t *testing.T) {
-	linttest.SimpleNegativeTest(t, `<?php
+	test := linttest.NewSuite(t)
+	test.Config.TypoFixer = misspell.New()
+	test.AddFile(`<?php
 interface Responsable {}
 
 /**
@@ -82,11 +80,13 @@ class c1 {
   private static function secret() {}
 }
 `)
+	test.RunAndMatch()
 }
 
 //nolint:misspell // misspelled on purpose
 func TestMisspellNamePositive(t *testing.T) {
 	test := linttest.NewSuite(t)
+	test.Config.TypoFixer = misspell.New()
 	test.AddFile(`<?php
 function unconditionnally_rollback() {}
 
@@ -120,7 +120,9 @@ class c {
 
 //nolint:misspell // misspelled on purpose
 func TestMisspellNameNegative(t *testing.T) {
-	linttest.SimpleNegativeTest(t, `<?php
+	test := linttest.NewSuite(t)
+	test.Config.TypoFixer = misspell.New()
+	test.AddFile(`<?php
 function includ() {
 }
 
@@ -128,4 +130,5 @@ class impelments {}
 
 class PostRedirect {}
 `)
+	test.RunAndMatch()
 }
diff --git a/src/tests/exprtype/exprtype_test.go b/src/tests/exprtype/exprtype_test.go
index 5bcdd9d..78dd0bf 100644
--- a/src/tests/exprtype/exprtype_test.go
+++ b/src/tests/exprtype/exprtype_test.go
@@ -2667,96 +2667,6 @@ function f() {
 	runExprTypeTest(t, &exprTypeTestParams{code: code})
 }
 
-func TestArrayFirstLastType(t *testing.T) {
-	code := `<?php
-class Foo {}
-
-/**
- * @return Foo[]
- */
-function returnFooArray() {
-	return [new Foo, new Foo, new Foo];
-}
-
-/**
- * @return mixed
- */
-function returnMixed() {}
-
-/**
- * @return mixed[]
- */
-function returnMixedArray() {}
-
-function f() {
-	$a = [10, 20, 30];
-	$b = array_last_element($a);
-	exprtype($b, "int");
-	
-	$c = [new Foo, new Foo, new Foo];
-	$d = array_last_element($c);
-	exprtype($d, "\Foo");
-
-	$e = returnFooArray();
-	$f = array_last_element($e);
-	exprtype($f, "\Foo");
-
-	$g = returnMixed();
-	$h = array_last_element($g);
-	exprtype($h, "mixed");
-
-	$i = returnMixedArray();
-	$j = array_last_element($i);
-	exprtype($j, "mixed");
-
-	$k = array_last_element([10, 20]);
-	exprtype($k, "int");
-
-	$l = array_last_element(20);
-	exprtype($l, "mixed");
-
-	$m = array_last_element();
-	exprtype($m, "mixed");
-}
-
-function f1() {
-	$a = [10, 20, 30];
-	$b = array_first_element($a);
-	exprtype($b, "int");
-	
-	$c = [new Foo, new Foo, new Foo];
-	$d = array_first_element($c);
-	exprtype($d, "\Foo");
-
-	$e = returnFooArray();
-	$f = array_first_element($e);
-	exprtype($f, "\Foo");
-
-	$g = returnMixed();
-	$h = array_first_element($g);
-	exprtype($h, "mixed");
-
-	$i = returnMixedArray();
-	$j = array_first_element($i);
-	exprtype($j, "mixed");
-
-	$k = array_first_element([10, 20]);
-	exprtype($k, "int");
-
-	$l = array_first_element(20);
-	exprtype($l, "mixed");
-
-	$m = array_first_element();
-	exprtype($m, "mixed");
-}
-`
-	linter.KPHP = true
-	defer func() {
-		linter.KPHP = false
-	}()
-	runExprTypeTest(t, &exprTypeTestParams{code: code, stubs: "<?php /* no code */"})
-}
-
 func TestNewFunctionReturnExprType(t *testing.T) {
 	code := `<?php
 class Foo {}
@@ -2932,22 +2842,30 @@ namespace Foo {
 }
 
 func runExprTypeTest(t *testing.T, params *exprTypeTestParams) {
+	exprTypeTestImpl(t, params, false)
+}
+
+func exprTypeTestImpl(t *testing.T, params *exprTypeTestParams, kphp bool) {
 	meta.ResetInfo()
+
+	config := linter.NewConfig()
+	config.KPHP = kphp
+	l := linter.NewLinter(config)
 	if params.stubs != "" {
-		linter.InitStubs(func(ch chan workspace.FileInfo) {
+		l.InitStubs(func(ch chan workspace.FileInfo) {
 			ch <- workspace.FileInfo{
 				Name:     "stubs.php",
 				Contents: []byte(params.stubs),
 			}
 		})
 	}
-	linttest.ParseTestFile(t, "exprtype.php", params.code)
+	linttest.ParseTestFile(t, l, "exprtype.php", params.code)
 
 	meta.SetIndexingComplete(true)
 
 	// Reset results map and run expr type collector.
 	exprTypeResult = map[ir.Node]meta.TypesMap{}
-	result := linttest.ParseTestFile(t, "exprtype.php", params.code)
+	result := linttest.ParseTestFile(t, l, "exprtype.php", params.code)
 
 	// Check that collected types are identical to the expected types.
 	// We need the second walker to pass *testing.T parameter to
diff --git a/src/tests/exprtype/kphp_test.go b/src/tests/exprtype/kphp_test.go
index a4b8cef..a31553f 100644
--- a/src/tests/exprtype/kphp_test.go
+++ b/src/tests/exprtype/kphp_test.go
@@ -2,8 +2,6 @@ package exprtype_test
 
 import (
 	"testing"
-
-	"github.com/VKCOM/noverify/src/linter"
 )
 
 func TestExprTypeAny(t *testing.T) {
@@ -57,8 +55,92 @@ function f() {
 	runKPHPExprTypeTest(t, &exprTypeTestParams{code: code, stubs: "<?php /* no code */"})
 }
 
+func TestArrayFirstLastType(t *testing.T) {
+	code := `<?php
+class Foo {}
+
+/**
+ * @return Foo[]
+ */
+function returnFooArray() {
+	return [new Foo, new Foo, new Foo];
+}
+
+/**
+ * @return mixed
+ */
+function returnMixed() {}
+
+/**
+ * @return mixed[]
+ */
+function returnMixedArray() {}
+
+function f() {
+	$a = [10, 20, 30];
+	$b = array_last_element($a);
+	exprtype($b, "int");
+	
+	$c = [new Foo, new Foo, new Foo];
+	$d = array_last_element($c);
+	exprtype($d, "\Foo");
+
+	$e = returnFooArray();
+	$f = array_last_element($e);
+	exprtype($f, "\Foo");
+
+	$g = returnMixed();
+	$h = array_last_element($g);
+	exprtype($h, "mixed");
+
+	$i = returnMixedArray();
+	$j = array_last_element($i);
+	exprtype($j, "mixed");
+
+	$k = array_last_element([10, 20]);
+	exprtype($k, "int");
+
+	$l = array_last_element(20);
+	exprtype($l, "mixed");
+
+	$m = array_last_element();
+	exprtype($m, "mixed");
+}
+
+function f1() {
+	$a = [10, 20, 30];
+	$b = array_first_element($a);
+	exprtype($b, "int");
+	
+	$c = [new Foo, new Foo, new Foo];
+	$d = array_first_element($c);
+	exprtype($d, "\Foo");
+
+	$e = returnFooArray();
+	$f = array_first_element($e);
+	exprtype($f, "\Foo");
+
+	$g = returnMixed();
+	$h = array_first_element($g);
+	exprtype($h, "mixed");
+
+	$i = returnMixedArray();
+	$j = array_first_element($i);
+	exprtype($j, "mixed");
+
+	$k = array_first_element([10, 20]);
+	exprtype($k, "int");
+
+	$l = array_first_element(20);
+	exprtype($l, "mixed");
+
+	$m = array_first_element();
+	exprtype($m, "mixed");
+}
+`
+	runKPHPExprTypeTest(t, &exprTypeTestParams{code: code, stubs: "<?php /* no code */"})
+}
+
 func runKPHPExprTypeTest(t *testing.T, params *exprTypeTestParams) {
-	linter.KPHP = true
-	runExprTypeTest(t, params)
-	linter.KPHP = false
+	exprTypeTestImpl(t, params, true)
 }
diff --git a/src/tests/golden/golden_test.go b/src/tests/golden/golden_test.go
index 4398441..1286fa5 100644
--- a/src/tests/golden/golden_test.go
+++ b/src/tests/golden/golden_test.go
@@ -6,11 +6,12 @@ import (
 
 	"github.com/VKCOM/noverify/src/linter"
 	"github.com/VKCOM/noverify/src/linttest"
-	"github.com/VKCOM/noverify/src/rules"
 )
 
+var linterConfig = linter.NewConfig()
+
 func TestMain(t *testing.M) {
-	err := linttest.InitEmbeddedRules()
+	err := linttest.InitEmbeddedRules(linterConfig)
 	if err != nil {
 		panic(err)
 	}
@@ -21,10 +22,6 @@ func TestMain(t *testing.M) {
 }
 
 func TestGolden(t *testing.T) {
-	defer func(rset *rules.Set) {
-		linter.Rules = rset
-	}(linter.Rules)
-
 	targets := []*linttest.GoldenTestSuite{
 		{
 			Name: "embeddedrules",
@@ -185,7 +182,7 @@ func TestGolden(t *testing.T) {
 	e2eSuite := linttest.NewGoldenE2ETestSuite(t)
 
 	for _, target := range targets {
-		linttest.PrepareGoldenTestSuite(target, t, "testdata", "golden.txt")
+		linttest.PrepareGoldenTestSuite(target, t, linterConfig, "testdata", "golden.txt")
 		target.Run()
 		e2eSuite.AddTest(target)
 	}
diff --git a/src/tests/golden/quickfix_test.go b/src/tests/golden/quickfix_test.go
index d0503c8..263c4a8 100644
--- a/src/tests/golden/quickfix_test.go
+++ b/src/tests/golden/quickfix_test.go
@@ -10,7 +10,6 @@ import (
 
 	"github.com/google/go-cmp/cmp"
 
-	"github.com/VKCOM/noverify/src/linter"
 	"github.com/VKCOM/noverify/src/linttest"
 )
 
@@ -49,6 +48,11 @@ func openFile(filename string) (f *os.File, found bool, err error) {
 }
 
 func (t *quickFixTest) runQuickFixTest() {
+	defer func(applyQuickFixes bool) {
+		linterConfig.ApplyQuickFixes = applyQuickFixes
+	}(linterConfig.ApplyQuickFixes)
+	linterConfig.ApplyQuickFixes = true
+
 	files, err := linttest.FindPHPFiles(t.folder)
 	if err != nil {
 		t.t.Fatalf("Error while searching for files in the %s folder: %s", t.folder, err)
@@ -91,11 +95,8 @@ func (t *quickFixTest) runQuickFixTest() {
 			}
 
 			test := linttest.NewSuite(t)
+			test.Config = linterConfig
 			test.AddNamedFile(fixedFileName, string(testFileContent))
-			linter.ApplyQuickFixes = true
-			defer func() {
-				linter.ApplyQuickFixes = false
-			}()
 			_ = test.RunLinter()
 
 			fixedFileContent, err := ioutil.ReadFile(fixedFileName)
diff --git a/src/tests/rules/rules_test.go b/src/tests/rules/rules_test.go
index c95e32a..039a5bf 100644
--- a/src/tests/rules/rules_test.go
+++ b/src/tests/rules/rules_test.go
@@ -430,8 +430,7 @@ func runRulesTest(t *testing.T, test *linttest.Suite, rfile string) {
 	if err != nil {
 		t.Fatalf("parse rules: %v", err)
 	}
-	oldRules := linter.Rules
-	linter.Rules = rset
+	test.Config.Rules = rset
 
 	ruleNamesSet := make(map[string]struct{}, len(rset.Names))
 	for _, name := range rset.Names {
@@ -445,6 +444,4 @@ func runRulesTest(t *testing.T, test *linttest.Suite, rfile string) {
 		}
 	}
 	test.Match(filtered)
-
-	linter.Rules = oldRules
 }