src/goCover.ts: handle Windows file system and fix profile parsing

The first element of code coverage profile could be one of

 <import_path>/<go_filename> or
 <absolute_path_to_dir>/<go_filename> or
 ./<go_filename>

We considered the first case, but not the other cases.
Handle the other cases.

This CL also addresses bugs in Windows.
 - The file path can include ':' so, parsing `go list` output
   base on ':' does not work on Windows. Instead, we refactored
   getNonVendorPackages that does the same job, created a new
   getImportPathToFolder, and changed to use it.
 - While refactoring, removed the code to support go versions
   older than 1.9 - whose 'go list' results include vendored packages.
 - Fixed the cover tests to use the correct separator.

And, refactored the code in a way to avoid global variables
if possible.

Update golang/vscode-go#239.
Update #40251.

Change-Id: I60d2466c91aba95b4cb01597ad9d6ed7f9f36230
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/243177
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
Reviewed-by: Peter Weinberger <pjw@google.com>
diff --git a/src/goCover.ts b/src/goCover.ts
index 2fa1c92..b1d3994 100644
--- a/src/goCover.ts
+++ b/src/goCover.ts
@@ -5,15 +5,13 @@
 
 'use strict';
 
-import cp = require('child_process');
 import fs = require('fs');
 import path = require('path');
-import util = require('util');
 import vscode = require('vscode');
 import { isModSupported } from './goModules';
+import { getImportPathToFolder } from './goPackages';
 import { getTestFlags, goTest, showTestOutput, TestConfig } from './testUtils';
-import { getBinPath, getCurrentGoPath, getGoConfig, getWorkspaceFolderPath } from './util';
-import { envPath } from './utils/goPath';
+import { getGoConfig } from './util';
 
 let gutterSvgs: { [key: string]: string };
 let decorators: {
@@ -31,6 +29,7 @@
 	coveredGutterStyle: string;
 	uncoveredGutterStyle: string;
 };
+
 // a list of modified, unsaved go files with actual code edits (rather than comment edits)
 let modifiedFiles: {
 	[key: string]: boolean;
@@ -155,18 +154,14 @@
 	coveredRange: vscode.Range[];
 }
 
-let coverageFiles: { [key: string]: CoverageData } = {};
-let coveragePath = new Map<string, CoverageData>();
-let pathsToDirs = new Map<string, string>();
+let coverageData: { [key: string]: CoverageData } = {};  // actual file path to the coverage data.
 let isCoverageApplied: boolean = false;
 
 /**
  * Clear the coverage on all files
  */
 function clearCoverage() {
-	coverageFiles = {};
-	coveragePath = new Map<string, CoverageData>();
-	pathsToDirs = new Map<string, string>();
+	coverageData = {};
 	disposeDecorators();
 	isCoverageApplied = false;
 }
@@ -180,6 +175,8 @@
 export function applyCodeCoverageToAllEditors(coverProfilePath: string, testDir?: string): Promise<void> {
 	const v = new Promise<void>((resolve, reject) => {
 		try {
+			const coveragePath = new Map<string, CoverageData>();  // <filename> from the cover profile to the coverage data.
+
 			// Clear existing coverage files
 			clearCoverage();
 
@@ -188,15 +185,22 @@
 			// for now read synchronously and hope for no errors
 			const contents = fs.readFileSync(coverProfilePath).toString();
 			contents.split('\n').forEach((line) => {
+				// go test coverageprofile generates output:
+				//    filename:StartLine.StartColumn,EndLine.EndColumn Hits CoverCount
+				// where the filename is either the import path + '/' + base file name, or
+				// the actual file path (either absolute or starting with .)
+				// See https://golang.org/issues/40251.
+				//
+				// The first line will be like "mode: set" which we will ignore.
 				const parse = line.match(/([^:]+)\:([\d]+)\.([\d]+)\,([\d]+)\.([\d]+)\s([\d]+)\s([\d]+)/);
 				if (!parse) { return; }
-				const lastSlash = parse[1].lastIndexOf('/'); // ok for windows?
+				const lastSlash = parse[1].lastIndexOf('/');
 				if (lastSlash !== -1) {
 					seenPaths.add(parse[1].slice(0, lastSlash));
 				}
 
 				// and fill in coveragePath
-				const coverage = getPathData(parse[1]);
+				const coverage = coveragePath.get(parse[1]) || { coveredRange: [], uncoveredRange: [] };
 				const range = new vscode.Range(
 					// Start Line converted to zero based
 					parseInt(parse[2], 10) - 1,
@@ -213,11 +217,12 @@
 				} else {
 					coverage.uncoveredRange.push(range);
 				}
-				setPathData(parse[1], coverage);
+				coveragePath.set(parse[1], coverage);
 			});
-			const pathPromise = getPathsToDirs(seenPaths, pathsToDirs, testDir);
-			pathPromise.then(() => {
-				createCoverageData();
+
+			getImportPathToFolder([...seenPaths], testDir)
+				.then((pathsToDirs) => {
+				createCoverageData(pathsToDirs, coveragePath);
 				setDecorators();
 				vscode.window.visibleTextEditors.forEach(applyCodeCoverage);
 				resolve();
@@ -230,47 +235,29 @@
 	return v;
 }
 
-/**
- * Get the object that holds the coverage data for given file path.
- * @param filePath
- */
-function getCoverageData(filePath: string): CoverageData {
-	if (filePath.startsWith('_')) {
-		filePath = filePath.substr(1);
-	}
-	if (process.platform === 'win32') {
-		const parts = filePath.split('/');
-		if (parts.length) {
-			filePath = parts.join(path.sep);
-		}
-	}
-	return coverageFiles[filePath] || { coveredRange: [], uncoveredRange: [] };
-}
+function createCoverageData(
+	pathsToDirs: Map<string, string>,
+	coveragePath: Map<string, CoverageData>) {
 
-/**
- * Get the CoverageData for an import path.
- * @param importPath
- */
-function getPathData(importPath: string): CoverageData {
-	return coveragePath.get(importPath) || { coveredRange: [], uncoveredRange: [] };
-}
-
-/**
- * Set the CoverageData for an import path.
- * @param importPath
- * @param data
- */
-function setPathData(importPath: string, data: CoverageData) {
-	coveragePath.set(importPath, data);
-}
-
-function createCoverageData() {
 	coveragePath.forEach((cd, ip) => {
 		const lastSlash = ip.lastIndexOf('/');
+		if (lastSlash === -1) {  // malformed
+			console.log(`invalid entry: ${ip}`);
+			return;
+		}
 		const importPath = ip.slice(0, lastSlash);
-		const fileDir = pathsToDirs.get(importPath);
-		const file = fileDir + ip.slice(lastSlash); // what about Windows?
-		setCoverageData(file, cd);
+		let fileDir = importPath;
+		if (path.isAbsolute(importPath)) {
+			// This is the true file path.
+		} else if (importPath.startsWith('.')) {
+			fileDir = path.resolve(fileDir);
+		} else {
+			// This is the package import path.
+			// we need to look up `go list` output stored in pathsToDir.
+			fileDir = pathsToDirs.get(importPath) || importPath;
+		}
+		const file = fileDir + path.sep + ip.slice(lastSlash + 1);
+		setCoverageDataByFilePath(file, cd);
 	});
 }
 
@@ -279,7 +266,7 @@
  * @param filePath
  * @param data
  */
-function setCoverageData(filePath: string, data: CoverageData) {
+function setCoverageDataByFilePath(filePath: string, data: CoverageData) {
 	if (filePath.startsWith('_')) {
 		filePath = filePath.substr(1);
 	}
@@ -289,7 +276,7 @@
 			filePath = parts.join(path.sep);
 		}
 	}
-	coverageFiles[filePath] = data;
+	coverageData[filePath] = data;
 }
 
 /**
@@ -303,16 +290,16 @@
 
 	const cfg = getGoConfig(editor.document.uri);
 	const coverageOptions = cfg['coverageOptions'];
-	for (const filename in coverageFiles) {
+	for (const filename in coverageData) {
 		if (editor.document.uri.fsPath.endsWith(filename)) {
 			isCoverageApplied = true;
-			const coverageData = coverageFiles[filename];
+			const cd = coverageData[filename];
 			if (coverageOptions === 'showCoveredCodeOnly' || coverageOptions === 'showBothCoveredAndUncoveredCode') {
 				editor.setDecorations(
 					decorators.type === 'gutter'
 						? decorators.coveredGutterDecorator
 						: decorators.coveredHighlightDecorator,
-					coverageData.coveredRange
+					cd.coveredRange
 				);
 			}
 
@@ -321,7 +308,7 @@
 					decorators.type === 'gutter'
 						? decorators.uncoveredGutterDecorator
 						: decorators.uncoveredHighlightDecorator,
-					coverageData.uncoveredRange
+					cd.uncoveredRange
 				);
 			}
 		}
@@ -371,41 +358,6 @@
 }
 
 /**
- * Fill the map of directory paths corresponding to input package paths
- * @param set Set<string> of import package paths
- */
-async function getPathsToDirs(set: Set<string>, res: Map<string, string>, testDir?: string) {
-	const goRuntimePath = getBinPath('go');
-	if (!goRuntimePath) {
-		vscode.window.showErrorMessage(
-			`Failed to run, as the "go" binary cannot be found in either GOROOT(${process.env['GOROOT']}) or PATH(${envPath})`
-		);
-	}
-	const args: string[] = ['list', '-f', '{{.ImportPath}}:{{.Dir}}'];
-	set.forEach((s) => args.push(s));
-
-	const options: { [key: string]: any } = {
-		env: Object.assign({}, process.env, { GOPATH: getCurrentGoPath() })
-	};
-	const workDir = getWorkspaceFolderPath();
-	// If there is a workDir then probably it is what we want
-	// Otherwise maybe a test suggested a directory.
-	if (workDir) {
-		options['cwd'] = workDir;
-	} else if (testDir) {
-		options['cwd'] = testDir;
-	}
-
-	const execFile = util.promisify(cp.execFile);
-	const { stdout } = await execFile(goRuntimePath, args, options);
-	stdout.split('\n').forEach((line) => {
-		const flds = line.split(':');
-		if (flds.length !== 2) { return; }
-		res.set(flds[0], flds[1]);
-	});
-}
-
-/**
  * If current editor has Code coverage applied, then remove it.
  * Else run tests to get the coverage and apply.
  */
@@ -459,7 +411,7 @@
 // These routines enable testing without starting an editing session.
 
 export function coverageFilesForTest():  { [key: string]: CoverageData; } {
-	return coverageFiles;
+	return coverageData;
 }
 
 export function initForTest() {
diff --git a/src/goPackages.ts b/src/goPackages.ts
index f072ef1..20eefef 100644
--- a/src/goPackages.ts
+++ b/src/goPackages.ts
@@ -260,6 +260,12 @@
  */
 export function getNonVendorPackages(
 	currentFolderPath: string, recursive: boolean = true): Promise<Map<string, string>> {
+
+	const target = recursive ? './...' : '.';  // go list ./... excludes vendor dirs since 1.9
+	return getImportPathToFolder([target], currentFolderPath);
+}
+
+export function getImportPathToFolder(targets: string[], cwd?: string): Promise<Map<string, string>> {
 	const goRuntimePath = getBinPath('go');
 	if (!goRuntimePath) {
 		console.warn(
@@ -267,12 +273,12 @@
 		);
 		return;
 	}
+
 	return new Promise<Map<string, string>>((resolve, reject) => {
-		const target = recursive ? './...' : '.';
 		const childProcess = cp.spawn(
 			goRuntimePath,
-			['list', '-f', 'ImportPath: {{.ImportPath}} FolderPath: {{.Dir}}', target],
-			{ cwd: currentFolderPath, env: toolExecutionEnvironment() }
+			['list', '-f', 'ImportPath: {{.ImportPath}} FolderPath: {{.Dir}}', ...targets],
+			{ cwd, env: toolExecutionEnvironment() }
 		);
 		const chunks: any[] = [];
 		childProcess.stdout.on('data', (stdout) => {
@@ -283,16 +289,13 @@
 			const lines = chunks.join('').toString().split('\n');
 			const result = new Map<string, string>();
 
-			const version = await getGoVersion();
-			const vendorAlreadyExcluded = version.gt('1.8');
-
 			lines.forEach((line) => {
 				const matches = line.match(pkgToFolderMappingRegex);
 				if (!matches || matches.length !== 3) {
 					return;
 				}
 				const [_, pkgPath, folderPath] = matches;
-				if (!pkgPath || (!vendorAlreadyExcluded && pkgPath.includes('/vendor/'))) {
+				if (!pkgPath) {
 					return;
 				}
 				result.set(pkgPath, folderPath);
diff --git a/src/testUtils.ts b/src/testUtils.ts
index b87ef32..aafe427 100644
--- a/src/testUtils.ts
+++ b/src/testUtils.ts
@@ -416,7 +416,7 @@
 		);
 	});
 	if (testconfig.applyCodeCoverage) {
-		await applyCodeCoverageToAllEditors(tmpCoverPath);
+		await applyCodeCoverageToAllEditors(tmpCoverPath, testconfig.dir);
 	}
 	return testResult;
 }
diff --git a/test/integration/coverage.test.ts b/test/integration/coverage.test.ts
index 10e37d2..fe38424 100644
--- a/test/integration/coverage.test.ts
+++ b/test/integration/coverage.test.ts
@@ -36,14 +36,9 @@
 		initForTest();
 		const x = vscode.workspace.openTextDocument(coverFilePath);
 		await applyCodeCoverageToAllEditors(coverFilePath, fixtureSourcePath);
-		let aDotGo: boolean;
-		let bDotGo: boolean;
-		for (const fn in coverageFilesForTest()) {
-			if (true) { // TSLint insists the body for for..in.. be an if-statement
-				if (fn === `${fixtureSourcePath}/a/a.go`) { aDotGo = true; }
-				if (fn === `${fixtureSourcePath}/b/b.go`) { bDotGo = true; }
-		}
-	}
-		assert.equal(aDotGo && bDotGo, true);
+		const files = Object.keys(coverageFilesForTest());
+		const aDotGo = files.includes(path.join(fixtureSourcePath, 'a', 'a.go'));
+		const bDotGo = files.includes(path.join(fixtureSourcePath, 'b', 'b.go'));
+		assert.equal(aDotGo && bDotGo, true, `seen a.go:${aDotGo}, seen b.go:${bDotGo}\n${files}\n`);
 	});
 });