Repository: VKCOM/noverify
Pull request #875 (2021-01-12 13:06:16)

linter: some fields from RootWalker have been moved to the workspace.File structure
Thanks to this change, the number of fields in the
`RootWalker` has decreased, and the signatures of some
functions have been simplified, which now accept a
single argument of type `*workspace.File`.


Patch:
diff --git a/src/cmd/main.go b/src/cmd/main.go
index ea5de92..e51faf9 100644
--- a/src/cmd/main.go
+++ b/src/cmd/main.go
@@ -419,15 +419,15 @@ func LoadEmbeddedStubs(filenames []string) error {
 
 	readStubs := func(ch chan workspace.FileInfo) {
 		for _, filename := range filenames {
-			data, err := stubs.Asset(filename)
+			contents, err := stubs.Asset(filename)
 			if err != nil {
 				log.Printf("Failed to read embedded %q file: %v", filename, err)
 				atomic.AddInt64(&errorsCount, 1)
 				continue
 			}
 			ch <- workspace.FileInfo{
-				Filename: filename,
-				Contents: data,
+				Name:     filename,
+				Contents: contents,
 			}
 		}
 	}
diff --git a/src/cmd/php-guru/dupcode/main.go b/src/cmd/php-guru/dupcode/main.go
index 0b7a06d..bcc763c 100644
--- a/src/cmd/php-guru/dupcode/main.go
+++ b/src/cmd/php-guru/dupcode/main.go
@@ -100,16 +100,16 @@ func Main(ctx *guru.Context) (int, error) {
 			irConverter := irconv.NewConverter(nil)
 			workerResult := make(funcSet)
 			for f := range filenamesCh {
-				data, err := ioutil.ReadFile(f.Filename)
+				data, err := ioutil.ReadFile(f.Name)
 				if err != nil {
-					log.Printf("read %s file: %v", f.Filename, err)
+					log.Printf("read %s file: %v", f.Name, err)
 				}
 				if !args.checkAutogen && workspace.FileIsAutoGenerated(data) {
 					continue
 				}
 				root, err := parseutil.ParseFile(data)
 				if err != nil {
-					log.Printf("parse %s file: %v", f.Filename, err)
+					log.Printf("parse %s file: %v", f.Name, err)
 					continue
 				}
 				rootIR := irConverter.ConvertRoot(root)
@@ -118,7 +118,7 @@ func Main(ctx *guru.Context) (int, error) {
 					st:           &meta.ClassParseState{},
 					funcs:        workerResult,
 					fileContents: data,
-					filename:     f.Filename,
+					filename:     f.Name,
 					args:         &args,
 					normLevel:    normLevel,
 				}
diff --git a/src/langsrv/langsrv.go b/src/langsrv/langsrv.go
index 22735aa..749aa62 100644
--- a/src/langsrv/langsrv.go
+++ b/src/langsrv/langsrv.go
@@ -15,6 +15,8 @@ import (
 	"sync"
 	"time"
 
+	"go.lsp.dev/uri"
+
 	"github.com/VKCOM/noverify/src/ir"
 	"github.com/VKCOM/noverify/src/ir/irconv"
 	"github.com/VKCOM/noverify/src/lintdebug"
@@ -24,7 +26,6 @@ import (
 	"github.com/VKCOM/noverify/src/solver"
 	"github.com/VKCOM/noverify/src/vscode"
 	"github.com/VKCOM/noverify/src/workspace"
-	"go.lsp.dev/uri"
 )
 
 const maxLength = 16 << 20
@@ -177,7 +178,7 @@ func handleInitialize(req *baseRequest) error {
 		// other files are not analyzed fully at all
 		openMapMutex.Lock()
 		for filename, op := range openMap {
-			go openFile(filename, op.contents)
+			go openFile(filename, string(op.file.Contents()))
 		}
 		openMapMutex.Unlock()
 	}()
@@ -383,9 +384,9 @@ func handleTextDocumentDefinition(req *baseRequest) error {
 
 	result := make([]vscode.Location, 0)
 
-	if params.Position.Line < len(f.linesPositions) {
+	if params.Position.Line < f.file.NumLinesPosition() {
 		w := &definitionWalker{
-			position: f.linesPositions[params.Position.Line] + params.Position.Character,
+			position: f.file.LinePosition(params.Position.Line) + params.Position.Character,
 			scopes:   f.scopes,
 		}
 		f.rootNode.Walk(w)
@@ -422,9 +423,9 @@ func handleTextDocumentReferences(req *baseRequest) error {
 
 	result := make([]vscode.Location, 0)
 
-	if params.Position.Line < len(f.linesPositions) {
+	if params.Position.Line < f.file.NumLinesPosition() {
 		w := &referencesWalker{
-			position: f.linesPositions[params.Position.Line] + params.Position.Character,
+			position: f.file.LinePosition(params.Position.Line) + params.Position.Character,
 		}
 		f.rootNode.Walk(w)
 		if len(w.result) > 0 {
@@ -491,12 +492,12 @@ func handleTextDocumentHover(req *baseRequest) (finalErr error) {
 	lnPos := params.Position.Line
 	chPos := params.Position.Character - 1
 
-	if lnPos >= len(f.lines) {
+	if lnPos >= f.file.NumLines() {
 		lintdebug.Send("Line out of range for file %s: %d", filename, lnPos)
 		return nil
 	}
 
-	ln := f.lines[lnPos]
+	ln := f.file.Line(lnPos)
 
 	if chPos < 0 || chPos >= len(ln) {
 		lintdebug.Send("Char out of range for file %s: line '%s', char %d", filename, ln, chPos)
@@ -504,7 +505,7 @@ func handleTextDocumentHover(req *baseRequest) (finalErr error) {
 	}
 
 	compl := &completionWalker{
-		position: f.linesPositions[params.Position.Line] + params.Position.Character,
+		position: f.file.LinePosition(params.Position.Line) + params.Position.Character,
 		scopes:   f.scopes,
 	}
 
@@ -634,19 +635,19 @@ func handleTextDocumentCompletion(req *baseRequest) error {
 	var ln []byte
 	var position int
 
-	if lnPos >= len(f.lines) {
+	if lnPos >= f.file.NumLines() {
 		lintdebug.Send("Line out of range for file %s: %d", filename, lnPos)
 		return nil
 	}
 
-	ln = f.lines[lnPos]
+	ln = f.file.Line(lnPos)
 
 	if chPos < 0 || chPos >= len(ln) {
 		lintdebug.Send("Char out of range for file %s: line '%s', char %d", filename, ln, chPos)
 		return nil
 	}
 
-	position = f.linesPositions[params.Position.Line] + params.Position.Character
+	position = f.file.LinePosition(params.Position.Line) + params.Position.Character
 
 	compl := &completionWalker{
 		position: position,
diff --git a/src/langsrv/refs.go b/src/langsrv/refs.go
index 071f802..77465a5 100644
--- a/src/langsrv/refs.go
+++ b/src/langsrv/refs.go
@@ -4,6 +4,8 @@ import (
 	"bytes"
 	"sync"
 
+	"go.lsp.dev/uri"
+
 	"github.com/VKCOM/noverify/src/ir"
 	"github.com/VKCOM/noverify/src/ir/irconv"
 	"github.com/VKCOM/noverify/src/lintdebug"
@@ -15,7 +17,6 @@ import (
 	"github.com/VKCOM/noverify/src/state"
 	"github.com/VKCOM/noverify/src/vscode"
 	"github.com/VKCOM/noverify/src/workspace"
-	"go.lsp.dev/uri"
 )
 
 type referencesWalker struct {
@@ -159,7 +160,7 @@ func copyOpenMap() map[string]string {
 	openMapMutex.Lock()
 	res := make(map[string]string, len(openMap))
 	for filename, info := range openMap {
-		res[filename] = info.contents
+		res[filename] = string(info.file.Contents())
 	}
 	openMapMutex.Unlock()
 
@@ -214,10 +215,10 @@ func findReferences(substr string, parse parseFn) []vscode.Location {
 		wg.Add(1)
 		go func() {
 			for fi := range ch {
-				contents, err := readFile(openMapCopy, fi.Filename)
+				contents, err := readFile(openMapCopy, fi.Name)
 				if err == nil && bytes.Contains(contents, substrBytes) {
 					func() {
-						waiter := linter.BeforeParse(len(contents), fi.Filename)
+						waiter := linter.BeforeParse(len(contents), fi.Name)
 						defer waiter.Finish()
 
 						parser := php7.NewParser(contents)
@@ -230,11 +231,11 @@ func findReferences(substr string, parse parseFn) []vscode.Location {
 							func() {
 								defer func() {
 									if r := recover(); r != nil {
-										lintdebug.Send("Panic while processing %s: %v", fi.Filename, r)
+										lintdebug.Send("Panic while processing %s: %v", fi.Name, r)
 									}
 								}()
 
-								found = parse(fi.Filename, rootIR, contents, parser)
+								found = parse(fi.Name, rootIR, contents, parser)
 							}()
 							resultMutex.Lock()
 							result = append(result, found...)
diff --git a/src/langsrv/refsvisitors.go b/src/langsrv/refsvisitors.go
index c583fb8..169c0ce 100644
--- a/src/langsrv/refsvisitors.go
+++ b/src/langsrv/refsvisitors.go
@@ -81,7 +81,7 @@ func findMethodReferences(className string, methodName string) []vscode.Location
 			},
 		)
 
-		rootWalker.InitFromParser(contents, parser)
+		rootWalker.InitCustomFileData(filename, contents)
 
 		rootNode.Walk(rootWalker)
 		linter.AnalyzeFileRootLevel(rootNode, rootWalker)
@@ -108,7 +108,7 @@ func findPropertyReferences(className string, propName string) []vscode.Location
 			},
 		)
 
-		rootWalker.InitFromParser(contents, parser)
+		rootWalker.InitCustomFileData(filename, contents)
 
 		rootNode.Walk(rootWalker)
 		linter.AnalyzeFileRootLevel(rootNode, rootWalker)
diff --git a/src/langsrv/state.go b/src/langsrv/state.go
index 6649e60..b9dea6e 100644
--- a/src/langsrv/state.go
+++ b/src/langsrv/state.go
@@ -7,20 +7,20 @@ import (
 	"sync"
 	"time"
 
+	"go.lsp.dev/uri"
+
 	"github.com/VKCOM/noverify/src/ir"
 	"github.com/VKCOM/noverify/src/lintdebug"
 	"github.com/VKCOM/noverify/src/linter"
 	"github.com/VKCOM/noverify/src/meta"
 	"github.com/VKCOM/noverify/src/vscode"
-	"go.lsp.dev/uri"
+	"github.com/VKCOM/noverify/src/workspace"
 )
 
 type openedFile struct {
-	rootNode       ir.Node
-	contents       string
-	scopes         map[ir.Node]*meta.Scope
-	lines          [][]byte
-	linesPositions []int
+	rootNode ir.Node
+	scopes   map[ir.Node]*meta.Scope
+	file     *workspace.File
 }
 
 var (
@@ -48,7 +48,10 @@ func openFile(filename, contents string) {
 	}
 
 	openMapMutex.Lock()
-	openMap[filename] = openedFile{rootNode: rootNode, contents: contents}
+	openMap[filename] = openedFile{
+		rootNode: rootNode,
+		file:     workspace.NewFile(filename, []byte(contents)),
+	}
 	openMapMutex.Unlock()
 }
 
@@ -86,7 +89,11 @@ func changeFileNonLocked(filename, contents string) {
 	linter.AnalyzeFileRootLevel(rootNode, newWalker)
 
 	openMapMutex.Lock()
-	f := openedFile{rootNode, contents, w.Scopes, w.Lines, w.LinesPositions}
+	f := openedFile{
+		rootNode: rootNode,
+		scopes:   w.Scopes,
+		file:     w.File(),
+	}
 	openMap[filename] = f
 	openMapMutex.Unlock()
 
diff --git a/src/linter/block_linter.go b/src/linter/block_linter.go
index 535e1e8..07cac51 100644
--- a/src/linter/block_linter.go
+++ b/src/linter/block_linter.go
@@ -576,7 +576,7 @@ func (b *blockLinter) addFixForArray(arr *ir.ArrayExpr) {
 
 	from := arr.Position.StartPos
 	to := arr.Position.EndPos
-	have := b.walker.r.fileContents[from:to]
+	have := b.walker.r.file.Contents()[from:to]
 	have = bytes.TrimPrefix(have, []byte("array("))
 	have = bytes.TrimSuffix(have, []byte(")"))
 
diff --git a/src/linter/custom.go b/src/linter/custom.go
index 43cf8be..f897279 100644
--- a/src/linter/custom.go
+++ b/src/linter/custom.go
@@ -12,6 +12,7 @@ import (
 	"github.com/VKCOM/noverify/src/quickfix"
 	"github.com/VKCOM/noverify/src/rules"
 	"github.com/VKCOM/noverify/src/vscode"
+	"github.com/VKCOM/noverify/src/workspace"
 )
 
 // MetaCacher is an interface for integrating checker-specific
@@ -163,12 +164,11 @@ func (ctx *RootContext) Filename() string {
 	return ctx.w.ctx.st.CurrentFile
 }
 
-// FileContents returns analyzed file source code.
-// Caller should not modify the returned slice.
+// File returns analyzed file.
 //
 // Experimental API.
-func (ctx *RootContext) FileContents() []byte {
-	return ctx.w.fileContents
+func (ctx *RootContext) File() *workspace.File {
+	return ctx.w.file
 }
 
 // BlockContext is the context for block checker.
@@ -233,9 +233,9 @@ func (ctx *BlockContext) Filename() string {
 	return ctx.w.r.ctx.st.CurrentFile
 }
 
-// FileContents returns the content of the file being analyzed.
-func (ctx *BlockContext) FileContents() []byte {
-	return ctx.w.r.fileContents
+// File returns the file being analyzed.
+func (ctx *BlockContext) File() *workspace.File {
+	return ctx.w.r.file
 }
 
 // AddQuickfix adds a new quick fix.
diff --git a/src/linter/parser.go b/src/linter/parser.go
index 887f93c..022a5aa 100644
--- a/src/linter/parser.go
+++ b/src/linter/parser.go
@@ -20,14 +20,23 @@ import (
 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)
+	file := workspace.FileInfo{
+		Name:       filename,
+		Contents:   contents,
+		LineRanges: lineRanges,
+	}
+	return w.ParseContents(file)
 }
 
 // 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)
+	file := workspace.FileInfo{
+		Name:     filename,
+		Contents: contents,
+	}
+	return w.IndexFile(file)
 }
 
 func cloneRulesForFile(filename string, ruleSet *rules.ScopedSet) *rules.ScopedSet {
diff --git a/src/linter/root.go b/src/linter/root.go
index 7d0ff34..930cc88 100644
--- a/src/linter/root.go
+++ b/src/linter/root.go
@@ -11,12 +11,10 @@ import (
 
 	"github.com/VKCOM/noverify/src/baseline"
 	"github.com/VKCOM/noverify/src/constfold"
-	"github.com/VKCOM/noverify/src/git"
 	"github.com/VKCOM/noverify/src/ir"
 	"github.com/VKCOM/noverify/src/ir/irutil"
 	"github.com/VKCOM/noverify/src/meta"
 	"github.com/VKCOM/noverify/src/php/parser/freefloating"
-	"github.com/VKCOM/noverify/src/php/parser/php7"
 	"github.com/VKCOM/noverify/src/php/parser/position"
 	"github.com/VKCOM/noverify/src/phpdoc"
 	"github.com/VKCOM/noverify/src/phpgrep"
@@ -34,10 +32,8 @@ const (
 
 // RootWalker is used to analyze root scope. Mostly defines, function and class definitions are analyzed.
 type RootWalker struct {
-	// autoGenerated is set to true when visiting auto-generated files.
-	autoGenerated bool
-
-	lineRanges []git.LineRange
+	// An associated file that is traversed by the current Root Walker.
+	file *workspace.File
 
 	custom      []RootChecker
 	customBlock []BlockCheckerCreateFunc
@@ -69,12 +65,6 @@ type RootWalker struct {
 
 	reports []*Report
 
-	fileContents []byte
-
-	// state required for both language server and reports creation
-	Lines          [][]byte
-	LinesPositions []int
-
 	// exposed meta-information for language server to use
 	Scopes      map[ir.Node]*meta.Scope
 	Diagnostics []vscode.Diagnostic
@@ -91,12 +81,8 @@ type phpDocParamsMap map[string]phpDocParamEl
 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(workerCtx, st),
-		autoGenerated:  prev.autoGenerated,
+		file: prev.file,
+		ctx:  newRootContext(workerCtx, st),
 	}
 }
 
@@ -111,20 +97,9 @@ func NewWalkerForReferencesSearcher(workerCtx *WorkerContext, filename string, b
 	return d
 }
 
-// InitFromParser initializes common fields that are needed for RootWalker work
-func (d *RootWalker) InitFromParser(contents []byte, parser *php7.Parser) {
-	lines := bytes.Split(contents, []byte("\n"))
-	linesPositions := make([]int, len(lines))
-	pos := 0
-	for idx, ln := range lines {
-		linesPositions[idx] = pos
-		pos += len(ln) + 1
-	}
-
-	d.fileContents = contents
-	d.LinesPositions = linesPositions
-	d.Lines = lines
-	d.autoGenerated = workspace.FileIsAutoGenerated(contents)
+// InitCustomFileData initializes file that are needed for RootWalker work for language server
+func (d *RootWalker) InitCustomFileData(filename string, contents []byte) {
+	d.file = workspace.NewFile(filename, contents)
 }
 
 // InitCustom is needed to initialize walker state
@@ -163,6 +138,11 @@ func (d *RootWalker) GetReports() []*Report {
 	return d.reports
 }
 
+// File returns file for current root walker.
+func (d *RootWalker) File() *workspace.File {
+	return d.file
+}
+
 // EnterNode is invoked at every node in hierarchy
 func (d *RootWalker) EnterNode(n ir.Node) (res bool) {
 	res = true
@@ -327,9 +307,9 @@ func (d *RootWalker) EnterNode(n ir.Node) (res bool) {
 }
 
 func (d *RootWalker) parseStartPos(pos *position.Position) (startLn []byte, startChar int) {
-	if pos.StartLine >= 1 && len(d.Lines) > pos.StartLine {
-		startLn = d.Lines[pos.StartLine-1]
-		p := d.LinesPositions[pos.StartLine-1]
+	if pos.StartLine >= 1 && d.file.NumLines() > pos.StartLine {
+		startLn = d.file.Line(pos.StartLine - 1)
+		p := d.file.LinePosition(pos.StartLine - 1)
 		if pos.StartPos > p {
 			startChar = pos.StartPos - p
 		}
@@ -342,7 +322,7 @@ func (d *RootWalker) report(n ir.Node, lineNumber int, level int, checkName, msg
 	if !meta.IsIndexingComplete() {
 		return
 	}
-	if d.autoGenerated && !CheckAutoGenerated {
+	if d.file.AutoGenerated() && !CheckAutoGenerated {
 		return
 	}
 	// We don't report anything if linter was disabled by a
@@ -384,9 +364,9 @@ func (d *RootWalker) report(n ir.Node, lineNumber int, level int, checkName, msg
 
 		startLn, startChar = d.parseStartPos(&pos)
 
-		if pos.EndLine >= 1 && len(d.Lines) > pos.EndLine {
-			endLn = d.Lines[pos.EndLine-1]
-			p := d.LinesPositions[pos.EndLine-1]
+		if pos.EndLine >= 1 && d.file.NumLines() > pos.EndLine {
+			endLn = d.file.Line(pos.EndLine - 1)
+			p := d.file.LinePosition(pos.EndLine - 1)
 			if pos.EndPos > p {
 				endChar = pos.EndPos - p
 			}
@@ -398,11 +378,11 @@ func (d *RootWalker) report(n ir.Node, lineNumber int, level int, checkName, msg
 			endChar = len(endLn)
 		}
 	} else if isReportForLine {
-		if lineNumber < 1 || lineNumber > len(d.Lines) {
+		if lineNumber < 1 || lineNumber > d.file.NumLines() {
 			return
 		}
 
-		startLn = d.Lines[lineNumber-1]
+		startLn = d.file.Line(lineNumber - 1)
 		startChar = 0
 		endChar = len(startLn)
 
@@ -507,10 +487,10 @@ func (d *RootWalker) reportHash(pos *position.Position, startLine []byte, checkN
 		// If this function is called, we expect that lines[index] exists.
 		index := pos.StartLine - 1
 		if index >= 1 {
-			prevLine = d.Lines[index-1]
+			prevLine = d.file.Line(index - 1)
 		}
-		if len(d.Lines) > index+1 {
-			nextLine = d.Lines[index+1]
+		if d.file.NumLines() > index+1 {
+			nextLine = d.file.Line(index + 1)
 		}
 	}
 
@@ -1810,7 +1790,7 @@ func (d *RootWalker) sourceNodeString(n ir.Node) string {
 	pos := ir.GetPosition(n)
 	from := pos.StartPos
 	to := pos.EndPos
-	src := d.fileContents
+	src := d.file.Contents()
 	// Taking a node from the source code preserves the original formatting
 	// and is more efficient than printing it.
 	if (from >= 0 && from < len(src)) && (to >= 0 && to < len(src)) {
@@ -2020,7 +2000,7 @@ func (d *RootWalker) checkKeywordCasePos(n ir.Node, begin int, keyword string) {
 	to := from + len(keyword)
 
 	wantKwd := keyword
-	haveKwd := d.fileContents[from:to]
+	haveKwd := d.file.Contents()[from:to]
 	if wantKwd != string(haveKwd) {
 		d.Report(n, LevelWarning, "keywordCase", "Use %s instead of %s",
 			wantKwd, haveKwd)
@@ -2035,7 +2015,7 @@ func (d *RootWalker) checkKeywordCase(n ir.Node, keyword string) {
 
 func (d *RootWalker) nodeText(n ir.Node) []byte {
 	pos := ir.GetPosition(n)
-	return d.fileContents[pos.StartPos:pos.EndPos]
+	return d.file.Contents()[pos.StartPos:pos.EndPos]
 }
 
 func (d *RootWalker) parseClassPHPDoc(n ir.Node, doc []phpdoc.CommentPart) classPhpDocParseResult {
diff --git a/src/linter/worker.go b/src/linter/worker.go
index d608b30..30475ae 100644
--- a/src/linter/worker.go
+++ b/src/linter/worker.go
@@ -16,7 +16,6 @@ import (
 
 	"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"
@@ -78,10 +77,10 @@ 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) {
+func (w *Worker) ParseContents(fileInfo workspace.FileInfo) (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())
+			s := fmt.Sprintf("Panic while parsing %s: %s\n\nStack trace: %s", fileInfo.Name, r, dbg.Stack())
 			log.Print(s)
 			err = errors.New(s)
 		}
@@ -93,10 +92,10 @@ func (w *Worker) ParseContents(filename string, contents []byte, lineRanges []gi
 	// We can simplify code below and read from files directly.
 
 	var rd inputs.ReadCloseSizer
-	if contents == nil {
-		rd, err = SrcInput.NewReader(filename)
+	if fileInfo.Contents == nil {
+		rd, err = SrcInput.NewReader(fileInfo.Name)
 	} else {
-		rd, err = SrcInput.NewBytesReader(filename, contents)
+		rd, err = SrcInput.NewBytesReader(fileInfo.Name, fileInfo.Contents)
 	}
 	if err != nil {
 		log.Panicf("open source input: %v", err)
@@ -108,9 +107,9 @@ func (w *Worker) ParseContents(filename string, contents []byte, lineRanges []gi
 	if _, err := b.ReadFrom(rd); err != nil {
 		return nil, nil, err
 	}
-	contents = append(make([]byte, 0, b.Len()), b.Bytes()...)
+	contents := append(make([]byte, 0, b.Len()), b.Bytes()...)
 
-	waiter := BeforeParse(len(contents), filename)
+	waiter := BeforeParse(len(contents), fileInfo.Name)
 	defer waiter.Finish()
 
 	parser := php7.NewParser(contents)
@@ -119,24 +118,26 @@ func (w *Worker) ParseContents(filename string, contents []byte, lineRanges []gi
 
 	atomic.AddInt64(&initParseTime, int64(time.Since(start)))
 
-	return w.analyzeFile(filename, contents, parser, lineRanges)
+	file := workspace.NewFile(fileInfo.Name, contents)
+	return w.analyzeFile(file, parser)
 }
 
 // IndexFile parses the file and fills in the meta info. Can use cache.
-func (w *Worker) IndexFile(filename string, contents []byte) error {
+func (w *Worker) IndexFile(file workspace.FileInfo) error {
 	if CacheDir == "" {
-		_, w, err := w.ParseContents(filename, contents, nil)
+		_, w, err := w.ParseContents(file)
 		if w != nil {
-			updateMetaInfo(filename, &w.meta)
+			updateMetaInfo(file.Name, &w.meta)
 		}
 		return err
 	}
 
+	var contents []byte
 	h := md5.New()
 
-	if contents == nil {
+	if file.Contents == nil {
 		start := time.Now()
-		fp, err := os.Open(filename)
+		fp, err := os.Open(file.Name)
 		if err != nil {
 			return err
 		}
@@ -151,14 +152,14 @@ func (w *Worker) IndexFile(filename string, contents []byte) error {
 
 	contentsHash := fmt.Sprintf("%x", h.Sum(nil))
 
-	cacheFilenamePart := filename
+	cacheFilenamePart := file.Name
 
-	volumeName := filepath.VolumeName(filename)
+	volumeName := filepath.VolumeName(file.Name)
 
 	// 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:]
+		cacheFilenamePart = file.Name[0:1] + "_" + file.Name[2:]
 	}
 
 	cacheFile := filepath.Join(CacheDir, cacheFilenamePart+"."+contentsHash)
@@ -166,25 +167,25 @@ func (w *Worker) IndexFile(filename string, contents []byte) error {
 	start := time.Now()
 	fp, err := os.Open(cacheFile)
 	if err != nil {
-		_, w, err := w.ParseContents(filename, contents, nil)
+		_, w, err := w.ParseContents(file)
 		if err != nil {
 			return err
 		}
 
-		return createMetaCacheFile(filename, cacheFile, w)
+		return createMetaCacheFile(file.Name, cacheFile, w)
 	}
 	defer fp.Close()
 
-	if err := restoreMetaFromCache(filename, fp); err != nil {
+	if err := restoreMetaFromCache(file.Name, fp); err != nil {
 		// do not really care about why exactly reading from cache failed
 		os.Remove(cacheFile)
 
-		_, w, err := w.ParseContents(filename, contents, nil)
+		_, w, err := w.ParseContents(file)
 		if err != nil {
 			return err
 		}
 
-		return createMetaCacheFile(filename, cacheFile, w)
+		return createMetaCacheFile(file.Name, cacheFile, w)
 	}
 
 	atomic.AddInt64(&initCacheReadTime, int64(time.Since(start)))
@@ -198,7 +199,7 @@ func (w *Worker) doParseFile(f workspace.FileInfo) []*Report {
 		start := time.Now()
 		defer func() {
 			if dur := time.Since(start); dur > DebugParseDuration {
-				log.Printf("Parsing of %s took %s", f.Filename, dur)
+				log.Printf("Parsing of %s took %s", f.Name, dur)
 			}
 		}()
 	}
@@ -207,43 +208,43 @@ func (w *Worker) doParseFile(f workspace.FileInfo) []*Report {
 
 	if w.needReports {
 		var walker *RootWalker
-		_, walker, err = w.ParseContents(f.Filename, f.Contents, f.LineRanges)
+		_, walker, err = w.ParseContents(f)
 		if err == nil {
 			reports = walker.GetReports()
 		}
 	} else {
-		err = w.IndexFile(f.Filename, f.Contents)
+		err = w.IndexFile(f)
 	}
 
 	if err != nil {
-		log.Printf("Failed parsing %s: %s", f.Filename, err.Error())
-		lintdebug.Send("Failed parsing %s: %s", f.Filename, err.Error())
+		log.Printf("Failed parsing %s: %s", f.Name, err.Error())
+		lintdebug.Send("Failed parsing %s: %s", f.Name, err.Error())
 	}
 
 	return reports
 }
 
-func (w *Worker) analyzeFile(filename string, contents []byte, parser *php7.Parser, lineRanges []git.LineRange) (*ir.Root, *RootWalker, error) {
+func (w *Worker) analyzeFile(file *workspace.File, parser *php7.Parser) (*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")
+		lintdebug.Send("Could not parse %s at all due to errors", file.Name())
+		return nil, nil, errors.New("empty root node")
 	}
 
 	rootIR := w.irconv.ConvertRoot(rootNode)
 
-	st := &meta.ClassParseState{CurrentFile: filename}
+	st := &meta.ClassParseState{CurrentFile: file.Name()}
 	walker := &RootWalker{
-		lineRanges: lineRanges,
-		ctx:        newRootContext(w.ctx, st),
+		file: file,
+		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),
+		anyRset:   cloneRulesForFile(file.Name(), Rules.Any),
+		rootRset:  cloneRulesForFile(file.Name(), Rules.Root),
+		localRset: cloneRulesForFile(file.Name(), Rules.Local),
 
 		reVet: &regexpVet{
 			parser: w.reParser,
@@ -256,7 +257,6 @@ func (w *Worker) analyzeFile(filename string, contents []byte, parser *php7.Pars
 		allowDisabledRegexp: w.AllowDisable,
 	}
 
-	walker.InitFromParser(contents, parser)
 	walker.InitCustom()
 
 	walker.beforeEnterFile()
@@ -267,8 +267,8 @@ func (w *Worker) analyzeFile(filename string, contents []byte, parser *php7.Pars
 	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)
+		if err := quickfix.Apply(file.Name(), file.Contents(), walker.ctx.fixes); err != nil {
+			linterError(file.Name(), "apply quickfix: %v", err)
 		}
 	}
 
diff --git a/src/workspace/file.go b/src/workspace/file.go
new file mode 100644
index 0000000..9b798c9
--- /dev/null
+++ b/src/workspace/file.go
@@ -0,0 +1,130 @@
+package workspace
+
+import (
+	"bytes"
+	"strings"
+
+	"github.com/VKCOM/noverify/src/git"
+)
+
+// FileInfo is a structure for storing information
+// about a file during file system traversal.
+//
+// Note: Contents do not always contain data.
+type FileInfo struct {
+	Name       string
+	Contents   []byte
+	LineRanges []git.LineRange
+}
+
+// File is a structure for storing information about
+// a file at the stage of indexing and analysis.
+//
+// Note: It is guaranteed that each field has a value.
+type File struct {
+	name     string
+	contents []byte
+
+	lines    [][]byte
+	linesPos []int
+
+	autoGenerated bool
+}
+
+func NewFile(name string, contents []byte) *File {
+	lines, linesPos := handleContents(contents)
+
+	return &File{
+		name:          name,
+		contents:      contents,
+		lines:         lines,
+		linesPos:      linesPos,
+		autoGenerated: FileIsAutoGenerated(contents),
+	}
+}
+
+func (f *File) Name() string {
+	return f.name
+}
+
+func (f *File) NumLines() int {
+	return len(f.lines)
+}
+
+func (f *File) Line(index int) []byte {
+	if index < 0 || index >= len(f.lines) {
+		return nil
+	}
+
+	return f.lines[index]
+}
+
+func (f *File) NumLinesPosition() int {
+	return len(f.linesPos)
+}
+
+func (f *File) LinePosition(index int) int {
+	if index < 0 || index >= len(f.linesPos) {
+		return -1
+	}
+	return f.linesPos[index]
+}
+
+func (f *File) Contents() []byte {
+	return f.contents
+}
+
+func (f *File) AutoGenerated() bool {
+	return f.autoGenerated
+}
+
+func handleContents(contents []byte) (lines [][]byte, linesPos []int) {
+	lines = bytes.Split(contents, []byte("\n"))
+	linesPos = make([]int, len(lines))
+	pos := 0
+	for idx, ln := range lines {
+		linesPos[idx] = pos
+		pos += len(ln) + 1
+	}
+	return lines, linesPos
+}
+
+func FileIsAutoGenerated(contents []byte) bool {
+	src := contents
+	maxLinesPeek := 15
+
+	doNotEdit := false
+	autoGenerated := false
+
+	for line := 0; line < maxLinesPeek; line++ {
+		if doNotEdit && autoGenerated {
+			return true
+		}
+		newline := bytes.IndexByte(src, '\n')
+		if newline == -1 {
+			break
+		}
+
+		l := strings.ToLower(string(src[:newline]))
+		src = src[newline+len("\n"):]
+
+		looksLikeComment := strings.HasPrefix(l, "//") ||
+			strings.HasPrefix(l, "/*") ||
+			strings.HasPrefix(l, " *")
+		if !looksLikeComment {
+			continue
+		}
+
+		doNotEdit = doNotEdit ||
+			strings.Contains(l, "do not edit") ||
+			strings.Contains(l, "don't edit") ||
+			strings.Contains(l, "do not modify") ||
+			strings.Contains(l, "don't modify")
+		autoGenerated = autoGenerated ||
+			strings.Contains(l, "auto-generated") ||
+			strings.Contains(l, "autogenerated") ||
+			strings.Contains(l, "generated by")
+	}
+
+	return doNotEdit && autoGenerated
+}
diff --git a/src/workspace/files.go b/src/workspace/files.go
index 2992ee3..eda420f 100644
--- a/src/workspace/files.go
+++ b/src/workspace/files.go
@@ -1,65 +1,15 @@
 package workspace
 
 import (
-	"bytes"
 	"log"
 	"os"
 	"path/filepath"
-	"strings"
 
 	"github.com/karrick/godirwalk"
-
-	"github.com/VKCOM/noverify/src/git"
 )
 
 type ReadCallback func(ch chan FileInfo)
 
-type FileInfo struct {
-	Filename   string
-	Contents   []byte
-	LineRanges []git.LineRange
-}
-
-func FileIsAutoGenerated(contents []byte) bool {
-	src := contents
-	maxLinesPeek := 15
-
-	doNotEdit := false
-	autoGenerated := false
-
-	for line := 0; line < maxLinesPeek; line++ {
-		if doNotEdit && autoGenerated {
-			return true
-		}
-		newline := bytes.IndexByte(src, '\n')
-		if newline == -1 {
-			break
-		}
-
-		l := strings.ToLower(string(src[:newline]))
-		src = src[newline+len("\n"):]
-
-		looksLikeComment := strings.HasPrefix(l, "//") ||
-			strings.HasPrefix(l, "/*") ||
-			strings.HasPrefix(l, " *")
-		if !looksLikeComment {
-			continue
-		}
-
-		doNotEdit = doNotEdit ||
-			strings.Contains(l, "do not edit") ||
-			strings.Contains(l, "don't edit") ||
-			strings.Contains(l, "do not modify") ||
-			strings.Contains(l, "don't modify")
-		autoGenerated = autoGenerated ||
-			strings.Contains(l, "auto-generated") ||
-			strings.Contains(l, "autogenerated") ||
-			strings.Contains(l, "generated by")
-	}
-
-	return doNotEdit && autoGenerated
-}
-
 // ReadFilenames returns callback that reads filenames into channel
 func ReadFilenames(filenames []string, filter *FilenameFilter) ReadCallback {
 	return func(ch chan FileInfo) {
@@ -107,7 +57,9 @@ func readFilenames(ch chan<- FileInfo, filename string, filter *FilenameFilter)
 			return
 		}
 
-		ch <- FileInfo{Filename: filename}
+		ch <- FileInfo{
+			Name: filename,
+		}
 		return
 	}
 
@@ -147,7 +99,9 @@ func readFilenames(ch chan<- FileInfo, filename string, filter *FilenameFilter)
 				return nil
 			}
 
-			ch <- FileInfo{Filename: path}
+			ch <- FileInfo{
+				Name: path,
+			}
 			return nil
 		},
 	}
diff --git a/src/workspace/git_files.go b/src/workspace/git_files.go
index 2c64640..184b3bd 100644
--- a/src/workspace/git_files.go
+++ b/src/workspace/git_files.go
@@ -9,9 +9,10 @@ import (
 	"regexp"
 	"time"
 
+	"github.com/monochromegane/go-gitignore"
+
 	"github.com/VKCOM/noverify/src/git"
 	"github.com/VKCOM/noverify/src/meta"
-	"github.com/monochromegane/go-gitignore"
 )
 
 // ParseGitignoreFromDir tries to parse a gitignore file at path/.gitignore.
@@ -49,7 +50,7 @@ func ReadChangesFromWorkTree(dir string, changes []git.Change) ReadCallback {
 			}
 
 			ch <- FileInfo{
-				Filename: filename,
+				Name:     filename,
 				Contents: contents,
 			}
 		}
@@ -96,7 +97,7 @@ func ReadFilesFromGit(repo, commitSHA1 string, ignoreRegex *regexp.Regexp) ReadC
 				}
 
 				ch <- FileInfo{
-					Filename: filename,
+					Name:     filename,
 					Contents: contents,
 				}
 			},
@@ -144,7 +145,7 @@ func ReadOldFilesFromGit(repo, commitSHA1 string, changes []git.Change) ReadCall
 			},
 			func(filename string, contents []byte) {
 				ch <- FileInfo{
-					Filename:   filename,
+					Name:       filename,
 					Contents:   contents,
 					LineRanges: changedMap[filename],
 				}
@@ -195,7 +196,7 @@ func ReadFilesFromGitWithChanges(repo, commitSHA1 string, changes []git.Change)
 			},
 			func(filename string, contents []byte) {
 				ch <- FileInfo{
-					Filename:   filename,
+					Name:       filename,
 					Contents:   contents,
 					LineRanges: changedMap[filename],
 				}


Test patch:
diff --git a/src/linttest/linttest.go b/src/linttest/linttest.go
index 10215f4..f3645ef 100644
--- a/src/linttest/linttest.go
+++ b/src/linttest/linttest.go
@@ -20,6 +20,7 @@ import (
 	"github.com/VKCOM/noverify/src/linter"
 	"github.com/VKCOM/noverify/src/meta"
 	"github.com/VKCOM/noverify/src/rules"
+	"github.com/VKCOM/noverify/src/workspace"
 )
 
 func init() {
@@ -367,7 +368,11 @@ func shuffleFiles(files []TestFile) {
 
 func parseTestFile(t testing.TB, worker *linter.Worker, f TestFile) (rootNode *ir.Root, w *linter.RootWalker) {
 	var err error
-	rootNode, w, err = worker.ParseContents(f.Name, f.Data, nil)
+	file := workspace.FileInfo{
+		Name:     f.Name,
+		Contents: f.Data,
+	}
+	rootNode, w, err = worker.ParseContents(file)
 	if err != nil {
 		t.Fatalf("could not parse %s: %v", f.Name, err.Error())
 	}
diff --git a/src/tests/exprtype/exprtype_test.go b/src/tests/exprtype/exprtype_test.go
index 6f16095..a56bd12 100644
--- a/src/tests/exprtype/exprtype_test.go
+++ b/src/tests/exprtype/exprtype_test.go
@@ -2830,7 +2830,7 @@ func runExprTypeTest(t *testing.T, params *exprTypeTestParams) {
 	if params.stubs != "" {
 		linter.InitStubs(func(ch chan workspace.FileInfo) {
 			ch <- workspace.FileInfo{
-				Filename: "stubs.php",
+				Name:     "stubs.php",
 				Contents: []byte(params.stubs),
 			}
 		})