Repository: VKCOM/noverify
Pull request #722 (2020-08-29 22:35:40)

linter: add a Worker concept
A worker is a single goroutine that reads from a filename
channel and runs analysis over them.

We can keep some shared state between the RootWalker's that
are executed on the same goroutine if we'll express
that worker explicitly as an object.

The new Worker type expresses that linter handle that
stores the state that can be reused.

Right now it only contains the phpdoc types parser that
has an internal pool of expressions. If it's used several
times, it becomes zero-alloc. If we reuse it for all RootWalker's
inside for a worker, we'll cut a few redundant allocations.

When php-parser will be updated and *Parser object will become
reusable, we can store PHP parser inside the worker state as well.

I also have some plans to make irconv package run faster.
In order to do that, keeping the converter can be beneficial.

Old linter.ParseContents and linter.IndexFile API remains,
but it's deprecated in favor of Worker-based API that is more efficient.

Right now we use Worker-based API in normal linter mode as well
as in linter tests (linttest pkg). The language server continues
to use the old API.

Signed-off-by: Iskander Sharipov <quasilyte@gmail.com>


Patch:
diff --git a/src/langsrv/refsvisitors.go b/src/langsrv/refsvisitors.go
index 01d0039..c583fb8 100644
--- a/src/langsrv/refsvisitors.go
+++ b/src/langsrv/refsvisitors.go
@@ -68,6 +68,7 @@ func findMethodReferences(className string, methodName string) []vscode.Location
 		var found []vscode.Location
 
 		rootWalker := linter.NewWalkerForReferencesSearcher(
+			linter.NewWorkerContext(),
 			filename,
 			func(ctx *linter.BlockContext) linter.BlockChecker {
 				return &blockMethodCallVisitor{
@@ -94,6 +95,7 @@ func findPropertyReferences(className string, propName string) []vscode.Location
 		var found []vscode.Location
 
 		rootWalker := linter.NewWalkerForReferencesSearcher(
+			linter.NewWorkerContext(),
 			filename,
 			func(ctx *linter.BlockContext) linter.BlockChecker {
 				return &blockPropertyVisitor{
diff --git a/src/langsrv/state.go b/src/langsrv/state.go
index f7aa645..6650e8a 100644
--- a/src/langsrv/state.go
+++ b/src/langsrv/state.go
@@ -79,7 +79,7 @@ func changeFileNonLocked(filename, contents string) {
 
 	meta.SetIndexingComplete(true)
 
-	newWalker := linter.NewWalkerForLangServer(w)
+	newWalker := linter.NewWalkerForLangServer(linter.NewWorkerContext(), w)
 
 	newWalker.InitCustom()
 	rootNode.Walk(newWalker)
diff --git a/src/linter/cache.go b/src/linter/cache.go
index 66b27a0..aa34ea3 100644
--- a/src/linter/cache.go
+++ b/src/linter/cache.go
@@ -2,7 +2,6 @@ package linter
 
 import (
 	"bufio"
-	"crypto/md5"
 	"encoding/gob"
 	"errors"
 	"fmt"
@@ -10,8 +9,6 @@ import (
 	"os"
 	"path/filepath"
 	"runtime"
-	"sync/atomic"
-	"time"
 
 	"github.com/VKCOM/noverify/src/meta"
 )
@@ -61,75 +58,6 @@ type fileMeta struct {
 	FunctionOverrides meta.FunctionsOverrideMap
 }
 
-// IndexFile parses the file and fills in the meta info. Can use cache.
-func IndexFile(filename string, contents []byte) error {
-	if CacheDir == "" {
-		_, w, err := ParseContents(filename, contents, nil, nil)
-		if w != nil {
-			updateMetaInfo(filename, &w.meta)
-		}
-		return err
-	}
-
-	h := md5.New()
-
-	if contents == nil {
-		start := time.Now()
-		fp, err := os.Open(filename)
-		if err != nil {
-			return err
-		}
-		defer fp.Close()
-		if _, err := io.Copy(h, fp); err != nil {
-			return err
-		}
-		atomic.AddInt64(&initFileReadTime, int64(time.Since(start)))
-	} else {
-		h.Write(contents)
-	}
-
-	contentsHash := fmt.Sprintf("%x", h.Sum(nil))
-
-	cacheFilenamePart := filename
-
-	volumeName := filepath.VolumeName(filename)
-
-	// windows user supplied full path to directory to be analyzed,
-	// but windows paths does not support ":" in the middle
-	if len(volumeName) == 2 && volumeName[1] == ':' {
-		cacheFilenamePart = filename[0:1] + "_" + filename[2:]
-	}
-
-	cacheFile := filepath.Join(CacheDir, cacheFilenamePart+"."+contentsHash)
-
-	start := time.Now()
-	fp, err := os.Open(cacheFile)
-	if err != nil {
-		_, w, err := ParseContents(filename, contents, nil, nil)
-		if err != nil {
-			return err
-		}
-
-		return createMetaCacheFile(filename, cacheFile, w)
-	}
-	defer fp.Close()
-
-	if err := restoreMetaFromCache(filename, fp); err != nil {
-		// do not really care about why exactly reading from cache failed
-		os.Remove(cacheFile)
-
-		_, w, err := ParseContents(filename, contents, nil, nil)
-		if err != nil {
-			return err
-		}
-
-		return createMetaCacheFile(filename, cacheFile, w)
-	}
-
-	atomic.AddInt64(&initCacheReadTime, int64(time.Since(start)))
-	return nil
-}
-
 func writeMetaCache(w *bufio.Writer, root *RootWalker) error {
 	if err := writeMetaCacheHeader(w, root); err != nil {
 		return err
diff --git a/src/linter/parser.go b/src/linter/parser.go
index 8233dfa..ca07820 100644
--- a/src/linter/parser.go
+++ b/src/linter/parser.go
@@ -2,74 +2,33 @@ package linter
 
 import (
 	"bytes"
-	"errors"
-	"fmt"
 	"log"
 	"regexp"
-	dbg "runtime/debug"
 	"strings"
 	"sync"
-	"sync/atomic"
 	"time"
 
-	"github.com/quasilyte/regex/syntax"
-
 	"github.com/VKCOM/noverify/src/git"
-	"github.com/VKCOM/noverify/src/inputs"
 	"github.com/VKCOM/noverify/src/ir"
-	"github.com/VKCOM/noverify/src/ir/irconv"
 	"github.com/VKCOM/noverify/src/lintdebug"
 	"github.com/VKCOM/noverify/src/meta"
-	"github.com/VKCOM/noverify/src/php/parser/php7"
-	"github.com/VKCOM/noverify/src/quickfix"
 	"github.com/VKCOM/noverify/src/rules"
 	"github.com/VKCOM/noverify/src/workspace"
 )
 
-// ParseContents parses specified contents (or file) and returns *RootWalker.
-// Function does not update global meta.
-func ParseContents(filename string, contents []byte, lineRanges []git.LineRange, allowDisabled *regexp.Regexp) (rootNode *ir.Root, w *RootWalker, err error) {
-	defer func() {
-		if r := recover(); r != nil {
-			s := fmt.Sprintf("Panic while parsing %s: %s\n\nStack trace: %s", filename, r, dbg.Stack())
-			log.Print(s)
-			err = errors.New(s)
-		}
-	}()
-
-	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 contents == nil {
-		rd, err = SrcInput.NewReader(filename)
-	} else {
-		rd, err = SrcInput.NewBytesReader(filename, contents)
-	}
-	if err != nil {
-		log.Panicf("open source input: %v", err)
-	}
-	defer rd.Close()
-
-	b := bytesBufPool.Get().(*bytes.Buffer)
-	b.Reset()
-	defer bytesBufPool.Put(b)
-
-	b.ReadFrom(rd)
-	contents = append(make([]byte, 0, b.Len()), b.Bytes()...)
-
-	waiter := BeforeParse(len(contents), filename)
-	defer waiter.Finish()
-
-	parser := php7.NewParser(contents)
-	parser.WithFreeFloating()
-	parser.Parse()
-
-	atomic.AddInt64(&initParseTime, int64(time.Since(start)))
+// ParseContents is a legacy way of linting files.
+// Deprecated: use Worker.ParseContents instead.
+func ParseContents(filename string, contents []byte, lineRanges []git.LineRange, allowDisabled *regexp.Regexp) (root *ir.Root, walker *RootWalker, err error) {
+	w := NewLintingWorker(0)
+	w.AllowDisable = allowDisabled
+	return w.ParseContents(filename, contents, lineRanges)
+}
 
-	return analyzeFile(filename, contents, parser, lineRanges, allowDisabled)
+// IndexFile is a legacy way of indexing files.
+// Deprecated: use Worker.IndexFile instead.
+func IndexFile(filename string, contents []byte) error {
+	w := NewIndexingWorker(0)
+	return w.IndexFile(filename, contents)
 }
 
 func cloneRulesForFile(filename string, ruleSet *rules.ScopedSet) *rules.ScopedSet {
@@ -91,67 +50,6 @@ func cloneRulesForFile(filename string, ruleSet *rules.ScopedSet) *rules.ScopedS
 	return &clone
 }
 
-func analyzeFile(filename string, contents []byte, parser *php7.Parser, lineRanges []git.LineRange, allowedDisabled *regexp.Regexp) (*ir.Root, *RootWalker, error) {
-	start := time.Now()
-	rootNode := parser.GetRootNode()
-
-	if rootNode == nil {
-		lintdebug.Send("Could not parse %s at all due to errors", filename)
-		return nil, nil, errors.New("Empty root node")
-	}
-
-	rootIR := irconv.ConvertRoot(rootNode)
-
-	st := &meta.ClassParseState{CurrentFile: filename}
-	w := &RootWalker{
-		lineRanges: lineRanges,
-		ctx:        newRootContext(st),
-
-		// We clone rules sets to remove all rules that
-		// should not be applied to this file because of the @path.
-		anyRset:   cloneRulesForFile(filename, Rules.Any),
-		rootRset:  cloneRulesForFile(filename, Rules.Root),
-		localRset: cloneRulesForFile(filename, Rules.Local),
-
-		reVet: &regexpVet{
-			parser: syntax.NewParser(&syntax.ParserOptions{
-				NoLiterals: false,
-			}),
-		},
-		reSimplifier: &regexpSimplifier{
-			parser: syntax.NewParser(&syntax.ParserOptions{
-				NoLiterals: true,
-			}),
-			out: &strings.Builder{},
-		},
-
-		allowDisabledRegexp: allowedDisabled,
-	}
-
-	w.InitFromParser(contents, parser)
-	w.InitCustom()
-
-	rootIR.Walk(w)
-	if meta.IsIndexingComplete() {
-		AnalyzeFileRootLevel(rootIR, w)
-	}
-	w.afterLeaveFile()
-
-	if len(w.ctx.fixes) != 0 {
-		if err := quickfix.Apply(filename, contents, w.ctx.fixes); err != nil {
-			linterError(filename, "apply quickfix: %v", err)
-		}
-	}
-
-	for _, e := range parser.GetErrors() {
-		w.Report(nil, LevelError, "syntax", "Syntax error: "+e.String())
-	}
-
-	atomic.AddInt64(&initWalkTime, int64(time.Since(start)))
-
-	return rootIR, w, nil
-}
-
 // AnalyzeFileRootLevel does analyze file top-level code.
 // This method is exposed for language server use, you usually
 // do not need to call it yourself.
@@ -208,16 +106,23 @@ func ParseFilenames(readFileNamesFunc workspace.ReadCallback, allowDisabled *reg
 	var wg sync.WaitGroup
 	reportsCh := make(chan []*Report, MaxConcurrency)
 
+	wg.Add(MaxConcurrency)
 	for i := 0; i < MaxConcurrency; i++ {
-		wg.Add(1)
-		go func() {
+		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, doParseFile(f, needReports, allowDisabled)...)
+				rep = append(rep, w.doParseFile(f)...)
 			}
 			reportsCh <- rep
 			wg.Done()
-		}()
+		}(i)
 	}
 	wg.Wait()
 
@@ -229,36 +134,6 @@ func ParseFilenames(readFileNamesFunc workspace.ReadCallback, allowDisabled *reg
 	return allReports
 }
 
-func doParseFile(f workspace.FileInfo, needReports bool, allowDisabled *regexp.Regexp) (reports []*Report) {
-	var err error
-
-	if DebugParseDuration > 0 {
-		start := time.Now()
-		defer func() {
-			if dur := time.Since(start); dur > DebugParseDuration {
-				log.Printf("Parsing of %s took %s", f.Filename, dur)
-			}
-		}()
-	}
-
-	if needReports {
-		var w *RootWalker
-		_, w, err = ParseContents(f.Filename, f.Contents, f.LineRanges, allowDisabled)
-		if err == nil {
-			reports = w.GetReports()
-		}
-	} else {
-		err = IndexFile(f.Filename, f.Contents)
-	}
-
-	if err != nil {
-		log.Printf("Failed parsing %s: %s", f.Filename, err.Error())
-		lintdebug.Send("Failed parsing %s: %s", f.Filename, err.Error())
-	}
-
-	return reports
-}
-
 func InitStubs(readFileNamesFunc workspace.ReadCallback) {
 	meta.SetLoadingStubs(true)
 	ParseFilenames(readFileNamesFunc, nil)
diff --git a/src/linter/root.go b/src/linter/root.go
index 4fd3956..76171fb 100644
--- a/src/linter/root.go
+++ b/src/linter/root.go
@@ -88,24 +88,24 @@ type phpDocParamEl struct {
 type phpDocParamsMap map[string]phpDocParamEl
 
 // NewWalkerForLangServer creates a copy of RootWalker to make full analysis of a file
-func NewWalkerForLangServer(prev *RootWalker) *RootWalker {
+func NewWalkerForLangServer(workerCtx *WorkerContext, prev *RootWalker) *RootWalker {
 	st := &meta.ClassParseState{CurrentFile: prev.ctx.st.CurrentFile}
 	return &RootWalker{
 		fileContents:   prev.fileContents,
 		LinesPositions: prev.LinesPositions,
 		Lines:          prev.Lines,
 		lineRanges:     prev.lineRanges,
-		ctx:            newRootContext(st),
+		ctx:            newRootContext(workerCtx, st),
 		autoGenerated:  prev.autoGenerated,
 	}
 }
 
 // NewWalkerForReferencesSearcher allows to access full context of a parser so that we can perform complex
 // searches if needed.
-func NewWalkerForReferencesSearcher(filename string, block BlockCheckerCreateFunc) *RootWalker {
+func NewWalkerForReferencesSearcher(workerCtx *WorkerContext, filename string, block BlockCheckerCreateFunc) *RootWalker {
 	st := &meta.ClassParseState{CurrentFile: filename}
 	d := &RootWalker{
-		ctx:         newRootContext(st),
+		ctx:         newRootContext(workerCtx, st),
 		customBlock: []BlockCheckerCreateFunc{block},
 	}
 	return d
diff --git a/src/linter/root_context.go b/src/linter/root_context.go
index e7ed98f..5e94be8 100644
--- a/src/linter/root_context.go
+++ b/src/linter/root_context.go
@@ -6,15 +6,15 @@ import (
 
 	"github.com/VKCOM/noverify/src/baseline"
 	"github.com/VKCOM/noverify/src/meta"
-	"github.com/VKCOM/noverify/src/phpdoc"
 	"github.com/VKCOM/noverify/src/quickfix"
 )
 
 type rootContext struct {
+	*WorkerContext
+
 	st *meta.ClassParseState
 
-	typeNormalizer   typeNormalizer
-	phpdocTypeParser *phpdoc.TypeParser
+	typeNormalizer typeNormalizer
 
 	// shapes is a list of generated shape types for the current file.
 	shapes []shapeTypeInfo
@@ -25,17 +25,18 @@ type rootContext struct {
 	fixes []quickfix.TextEdit
 }
 
-func newRootContext(st *meta.ClassParseState) rootContext {
+func newRootContext(workerCtx *WorkerContext, st *meta.ClassParseState) rootContext {
 	var p baseline.FileProfile
 	if BaselineProfile != nil {
 		filename := filepath.Base(st.CurrentFile)
 		p = BaselineProfile.Files[filename]
 	}
 	return rootContext{
-		typeNormalizer:   typeNormalizer{st: st},
-		phpdocTypeParser: phpdoc.NewTypeParser(),
-		st:               st,
-		baseline:         p,
+		WorkerContext: workerCtx,
+
+		typeNormalizer: typeNormalizer{st: st},
+		st:             st,
+		baseline:       p,
 	}
 }
 
diff --git a/src/linter/worker.go b/src/linter/worker.go
new file mode 100644
index 0000000..eb813c5
--- /dev/null
+++ b/src/linter/worker.go
@@ -0,0 +1,271 @@
+package linter
+
+import (
+	"bytes"
+	"crypto/md5"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"path/filepath"
+	"regexp"
+	dbg "runtime/debug"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"github.com/VKCOM/noverify/src/git"
+	"github.com/VKCOM/noverify/src/inputs"
+	"github.com/VKCOM/noverify/src/ir"
+	"github.com/VKCOM/noverify/src/ir/irconv"
+	"github.com/VKCOM/noverify/src/lintdebug"
+	"github.com/VKCOM/noverify/src/meta"
+	"github.com/VKCOM/noverify/src/php/parser/php7"
+	"github.com/VKCOM/noverify/src/quickfix"
+	"github.com/VKCOM/noverify/src/workspace"
+	"github.com/quasilyte/regex/syntax"
+)
+
+// Worker is a linter handle that is expected to be executed in a single goroutine context.
+//
+// It's not thread-safe and contains the state that will be re-used between the linter API calls.
+//
+// See NewLintingWorker and NewIndexingWorker.
+type Worker struct {
+	id  int
+	ctx *WorkerContext
+
+	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
+}
+
+func newWorker(id int) *Worker {
+	return &Worker{
+		id:  id,
+		ctx: NewWorkerContext(),
+	}
+}
+
+func (w *Worker) ID() int { return w.id }
+
+// ParseContents parses specified contents (or file) and returns *RootWalker.
+// Function does not update global meta.
+func (w *Worker) ParseContents(filename string, contents []byte, lineRanges []git.LineRange) (root *ir.Root, walker *RootWalker, err error) {
+	defer func() {
+		if r := recover(); r != nil {
+			s := fmt.Sprintf("Panic while parsing %s: %s\n\nStack trace: %s", filename, r, dbg.Stack())
+			log.Print(s)
+			err = errors.New(s)
+		}
+	}()
+
+	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 contents == nil {
+		rd, err = SrcInput.NewReader(filename)
+	} else {
+		rd, err = SrcInput.NewBytesReader(filename, contents)
+	}
+	if err != nil {
+		log.Panicf("open source input: %v", err)
+	}
+	defer rd.Close()
+
+	b := bytesBufPool.Get().(*bytes.Buffer)
+	b.Reset()
+	defer bytesBufPool.Put(b)
+
+	b.ReadFrom(rd)
+	contents = append(make([]byte, 0, b.Len()), b.Bytes()...)
+
+	waiter := BeforeParse(len(contents), filename)
+	defer waiter.Finish()
+
+	parser := php7.NewParser(contents)
+	parser.WithFreeFloating()
+	parser.Parse()
+
+	atomic.AddInt64(&initParseTime, int64(time.Since(start)))
+
+	return w.analyzeFile(filename, contents, parser, lineRanges)
+}
+
+// IndexFile parses the file and fills in the meta info. Can use cache.
+func (w *Worker) IndexFile(filename string, contents []byte) error {
+	if CacheDir == "" {
+		_, w, err := w.ParseContents(filename, contents, nil)
+		if w != nil {
+			updateMetaInfo(filename, &w.meta)
+		}
+		return err
+	}
+
+	h := md5.New()
+
+	if contents == nil {
+		start := time.Now()
+		fp, err := os.Open(filename)
+		if err != nil {
+			return err
+		}
+		defer fp.Close()
+		if _, err := io.Copy(h, fp); err != nil {
+			return err
+		}
+		atomic.AddInt64(&initFileReadTime, int64(time.Since(start)))
+	} else {
+		h.Write(contents)
+	}
+
+	contentsHash := fmt.Sprintf("%x", h.Sum(nil))
+
+	cacheFilenamePart := filename
+
+	volumeName := filepath.VolumeName(filename)
+
+	// windows user supplied full path to directory to be analyzed,
+	// but windows paths does not support ":" in the middle
+	if len(volumeName) == 2 && volumeName[1] == ':' {
+		cacheFilenamePart = filename[0:1] + "_" + filename[2:]
+	}
+
+	cacheFile := filepath.Join(CacheDir, cacheFilenamePart+"."+contentsHash)
+
+	start := time.Now()
+	fp, err := os.Open(cacheFile)
+	if err != nil {
+		_, w, err := w.ParseContents(filename, contents, nil)
+		if err != nil {
+			return err
+		}
+
+		return createMetaCacheFile(filename, cacheFile, w)
+	}
+	defer fp.Close()
+
+	if err := restoreMetaFromCache(filename, fp); err != nil {
+		// do not really care about why exactly reading from cache failed
+		os.Remove(cacheFile)
+
+		_, w, err := w.ParseContents(filename, contents, nil)
+		if err != nil {
+			return err
+		}
+
+		return createMetaCacheFile(filename, cacheFile, w)
+	}
+
+	atomic.AddInt64(&initCacheReadTime, int64(time.Since(start)))
+	return nil
+}
+
+func (w *Worker) doParseFile(f workspace.FileInfo) []*Report {
+	var err error
+
+	if DebugParseDuration > 0 {
+		start := time.Now()
+		defer func() {
+			if dur := time.Since(start); dur > DebugParseDuration {
+				log.Printf("Parsing of %s took %s", f.Filename, dur)
+			}
+		}()
+	}
+
+	var reports []*Report
+
+	if w.needReports {
+		var walker *RootWalker
+		_, walker, err = w.ParseContents(f.Filename, f.Contents, f.LineRanges)
+		if err == nil {
+			reports = walker.GetReports()
+		}
+	} else {
+		err = w.IndexFile(f.Filename, f.Contents)
+	}
+
+	if err != nil {
+		log.Printf("Failed parsing %s: %s", f.Filename, err.Error())
+		lintdebug.Send("Failed parsing %s: %s", f.Filename, err.Error())
+	}
+
+	return reports
+}
+
+func (w *Worker) analyzeFile(filename string, contents []byte, parser *php7.Parser, lineRanges []git.LineRange) (*ir.Root, *RootWalker, error) {
+	start := time.Now()
+	rootNode := parser.GetRootNode()
+
+	if rootNode == nil {
+		lintdebug.Send("Could not parse %s at all due to errors", filename)
+		return nil, nil, errors.New("Empty root node")
+	}
+
+	rootIR := irconv.ConvertRoot(rootNode)
+
+	st := &meta.ClassParseState{CurrentFile: filename}
+	walker := &RootWalker{
+		lineRanges: lineRanges,
+		ctx:        newRootContext(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(filename, Rules.Any),
+		rootRset:  cloneRulesForFile(filename, Rules.Root),
+		localRset: cloneRulesForFile(filename, Rules.Local),
+
+		reVet: &regexpVet{
+			parser: syntax.NewParser(&syntax.ParserOptions{
+				NoLiterals: false,
+			}),
+		},
+		reSimplifier: &regexpSimplifier{
+			parser: syntax.NewParser(&syntax.ParserOptions{
+				NoLiterals: true,
+			}),
+			out: &strings.Builder{},
+		},
+
+		allowDisabledRegexp: w.AllowDisable,
+	}
+
+	walker.InitFromParser(contents, parser)
+	walker.InitCustom()
+
+	rootIR.Walk(walker)
+	if meta.IsIndexingComplete() {
+		AnalyzeFileRootLevel(rootIR, walker)
+	}
+	walker.afterLeaveFile()
+
+	if len(walker.ctx.fixes) != 0 {
+		if err := quickfix.Apply(filename, contents, walker.ctx.fixes); err != nil {
+			linterError(filename, "apply quickfix: %v", err)
+		}
+	}
+
+	for _, e := range parser.GetErrors() {
+		walker.Report(nil, LevelError, "syntax", "Syntax error: "+e.String())
+	}
+
+	atomic.AddInt64(&initWalkTime, int64(time.Since(start)))
+
+	return rootIR, walker, nil
+}
diff --git a/src/linter/worker_context.go b/src/linter/worker_context.go
new file mode 100644
index 0000000..abd0b50
--- /dev/null
+++ b/src/linter/worker_context.go
@@ -0,0 +1,22 @@
+package linter
+
+import (
+	"github.com/VKCOM/noverify/src/phpdoc"
+)
+
+// WorkerContext is a state that is shared between all worker-owned
+// RootWalker's and BlockWalker's.
+//
+// A worker is a separate goroutine that processed the incoming files.
+//
+// Since workerContext is worker-bound, that state is never accessed
+// from different threads, so we can re-use it without synchronization.
+type WorkerContext struct {
+	phpdocTypeParser *phpdoc.TypeParser
+}
+
+func NewWorkerContext() *WorkerContext {
+	return &WorkerContext{
+		phpdocTypeParser: phpdoc.NewTypeParser(),
+	}
+}


Test patch:
diff --git a/src/linter/phpdoc_test.go b/src/linter/phpdoc_test.go
index 82f7c34..d529eb2 100644
--- a/src/linter/phpdoc_test.go
+++ b/src/linter/phpdoc_test.go
@@ -59,7 +59,7 @@ func TestParseClassPHPDoc(t *testing.T) {
 	}
 
 	st := &meta.ClassParseState{}
-	walker := RootWalker{ctx: newRootContext(st)}
+	walker := RootWalker{ctx: newRootContext(NewWorkerContext(), st)}
 	for _, test := range tests {
 		doc := fmt.Sprintf(`/** %s */`, test.line)
 		result := walker.parseClassPHPDoc(nil, doc)
@@ -208,7 +208,7 @@ func TestTypeFilter(t *testing.T) {
 
 func BenchmarkParseTypes(b *testing.B) {
 	st := &meta.ClassParseState{}
-	ctx := newRootContext(st)
+	ctx := newRootContext(NewWorkerContext(), st)
 	typeString := `?x|array<int>|T[]`
 	b.ResetTimer()
 	for i := 0; i < b.N; i++ {
diff --git a/src/linttest/linttest.go b/src/linttest/linttest.go
index ea35960..9227fb0 100644
--- a/src/linttest/linttest.go
+++ b/src/linttest/linttest.go
@@ -213,13 +213,19 @@ func (s *Suite) RunLinter() []*linter.Report {
 		}
 	}
 
+	indexing := linter.NewIndexingWorker(0)
+	indexing.AllowDisable = s.AllowDisable
+
 	shuffleFiles(s.Files)
 	for _, f := range s.Files {
-		parseTestFile(s.t, f, s.AllowDisable)
+		parseTestFile(s.t, indexing, f)
 	}
 
 	meta.SetIndexingComplete(true)
 
+	linting := linter.NewLintingWorker(0)
+	linting.AllowDisable = s.AllowDisable
+
 	shuffleFiles(s.Files)
 	var reports []*linter.Report
 	for _, f := range s.Files {
@@ -230,7 +236,7 @@ func (s *Suite) RunLinter() []*linter.Report {
 			continue
 		}
 
-		_, w := parseTestFile(s.t, f, s.AllowDisable)
+		_, w := parseTestFile(s.t, linting, f)
 		reports = append(reports, w.GetReports()...)
 	}
 
@@ -252,10 +258,16 @@ func (s *Suite) RunLinter() []*linter.Report {
 
 // ParseTestFile parses given test file.
 func ParseTestFile(t *testing.T, filename, content string) (rootNode *ir.Root, w *linter.RootWalker) {
-	return parseTestFile(t, TestFile{
+	var worker *linter.Worker
+	if meta.IsIndexingComplete() {
+		worker = linter.NewLintingWorker(0)
+	} else {
+		worker = linter.NewIndexingWorker(0)
+	}
+	return parseTestFile(t, worker, TestFile{
 		Name: filename,
 		Data: []byte(content),
-	}, nil)
+	})
 }
 
 // RunFilterMatch calls Match with the filtered results of RunLinter.
@@ -290,9 +302,9 @@ func shuffleFiles(files []TestFile) {
 	})
 }
 
-func parseTestFile(t testing.TB, f TestFile, allowDisable *regexp.Regexp) (rootNode *ir.Root, w *linter.RootWalker) {
+func parseTestFile(t testing.TB, worker *linter.Worker, f TestFile) (rootNode *ir.Root, w *linter.RootWalker) {
 	var err error
-	rootNode, w, err = linter.ParseContents(f.Name, f.Data, nil, allowDisable)
+	rootNode, w, err = worker.ParseContents(f.Name, f.Data, nil)
 	if err != nil {
 		t.Fatalf("could not parse %s: %v", f.Name, err.Error())
 	}