src/goInstallTools: install dlv-dap (dev version of dlv)

The new debug mode that uses `dlv dap` needs the dev version
of dlv (newer than the official release). This CL installs
a separate copy of delve (dlv) as `dlv-dap`, built from master.
This `dlv-dap` is used only when debugging in dlv-dap mode.

If the local version of dlv-dap is older than the hard-coded
minimum required version (latestVersion), the extension will
prompt users to update, while resolving the debug configuration.

For now, we will not query the upstream release status
(as we are currently doing for gopls).

We can also consider to provide the auto-update feature
(as we are currently doing for gopls) in the future.

Fixes golang/vscode-go#794

Change-Id: I77d4b13b8eaa69d157d7c8f94d462503c92d7e55
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/297189
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Suzy Mueller <suzmue@golang.org>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Polina Sokolova <polina@google.com>
Reviewed-by: Suzy Mueller <suzmue@golang.org>
diff --git a/src/goDebugConfiguration.ts b/src/goDebugConfiguration.ts
index f87e395..6d5fa13 100644
--- a/src/goDebugConfiguration.ts
+++ b/src/goDebugConfiguration.ts
@@ -11,13 +11,16 @@
 import vscode = require('vscode');
 import { getGoConfig } from './config';
 import { toolExecutionEnvironment } from './goEnv';
-import { promptForMissingTool } from './goInstallTools';
+import { promptForMissingTool, promptForUpdatingTool, shouldUpdateTool } from './goInstallTools';
 import { packagePathToGoModPathMap } from './goModules';
+import { getToolAtVersion } from './goTools';
 import { pickProcess, pickProcessByName } from './pickProcess';
 import { getFromGlobalState, updateGlobalState } from './stateUtils';
 import { getBinPath, resolvePath } from './util';
 import { parseEnvFiles } from './utils/envUtils';
 
+let dlvDAPVersionCurrent = false;
+
 export class GoDebugConfigurationProvider implements vscode.DebugConfigurationProvider {
 	constructor(private defaultDebugAdapterType: string = 'go') {}
 
@@ -227,11 +230,22 @@
 			}
 		}
 
-		debugConfiguration['dlvToolPath'] = getBinPath('dlv');
-		if (!path.isAbsolute(debugConfiguration['dlvToolPath'])) {
-			promptForMissingTool('dlv');
+		const debugAdapter = debugConfiguration['debugAdapter'] === 'dlv-dap' ? 'dlv-dap' : 'dlv';
+		const dlvToolPath = getBinPath(debugAdapter);
+		if (!path.isAbsolute(dlvToolPath)) {
+			await promptForMissingTool(debugAdapter);
 			return;
 		}
+		debugConfiguration['dlvToolPath'] = dlvToolPath;
+
+		if (debugAdapter === 'dlv-dap' && !dlvDAPVersionCurrent) {
+			const tool = getToolAtVersion('dlv-dap');
+			if (await shouldUpdateTool(tool, dlvToolPath)) {
+				promptForUpdatingTool('dlv-dap');
+				return;
+			}
+			dlvDAPVersionCurrent = true;
+		}
 
 		if (debugConfiguration['mode'] === 'auto') {
 			debugConfiguration['mode'] =
diff --git a/src/goInstallTools.ts b/src/goInstallTools.ts
index cd7652d..9d37f52 100644
--- a/src/goInstallTools.ts
+++ b/src/goInstallTools.ts
@@ -32,6 +32,7 @@
 import {
 	getBinPath,
 	getBinPathWithExplanation,
+	getCheckForToolsUpdatesConfig,
 	getGoVersion,
 	getTempFilePath,
 	getWorkspaceFolderPath,
@@ -245,18 +246,22 @@
 	if (!modulesOn) {
 		args.push('-u');
 	}
-	// Tools with a "mod" suffix should not be installed,
-	// instead we run "go build -o" to rename them.
-	if (hasModSuffix(tool)) {
-		args.push('-d');
+	// dlv-dap or tools with a "mod" suffix can't be installed with
+	// simple `go install` or `go get`. We need to get, build, and rename them.
+	if (hasModSuffix(tool) || tool.name === 'dlv-dap') {
+		args.push('-d'); // get the version, but don't build.
 	}
 	let importPath: string;
 	if (!modulesOn) {
 		importPath = getImportPath(tool, goVersion);
 	} else {
-		let version = tool.version;
-		if (!version && tool.usePrereleaseInPreviewMode && isInPreviewMode()) {
-			version = await latestToolVersion(tool, true);
+		let version: semver.SemVer | string | undefined = tool.version;
+		if (!version) {
+			if (tool.usePrereleaseInPreviewMode && isInPreviewMode()) {
+				version = await latestToolVersion(tool, true);
+			} else if (tool.defaultVersion) {
+				version = tool.defaultVersion;
+			}
 		}
 		importPath = getImportPathWithVersion(tool, version, goVersion);
 	}
@@ -274,14 +279,16 @@
 		output = `${stdout} ${stderr}`;
 		logVerbose('install: %s %s\n%s%s', goBinary, args.join(' '), stdout, stderr);
 
-		if (hasModSuffix(tool)) {
-			// Actual installation of the -gomod tool is done by running go build.
+		if (hasModSuffix(tool) || tool.name === 'dlv-dap') {
+			// Actual installation of the -gomod tool and dlv-dap is done by running go build.
 			const gopath = env['GOBIN'] || env['GOPATH'];
 			if (!gopath) {
 				throw new Error('GOBIN/GOPATH not configured in environment');
 			}
 			const destDir = gopath.split(path.delimiter)[0];
 			const outputFile = path.join(destDir, 'bin', process.platform === 'win32' ? `${tool.name}.exe` : tool.name);
+			// go build does not take @version suffix yet.
+			const importPath = getImportPath(tool, goVersion);
 			await execFile(goBinary, ['build', '-o', outputFile, importPath], opts);
 		}
 		const toolInstallPath = getBinPath(tool.name);
@@ -619,3 +626,50 @@
 	}
 	return ret;
 }
+
+// inspectGoToolVersion reads the go version and module version
+// of the given go tool using `go version -m` command.
+export async function inspectGoToolVersion(binPath: string): Promise<{ goVersion?: string; moduleVersion?: string }> {
+	const goCmd = getBinPath('go');
+	const execFile = util.promisify(cp.execFile);
+	try {
+		const { stdout } = await execFile(goCmd, ['version', '-m', binPath]);
+		/* The output format will look like this:
+			/Users/hakim/go/bin/gopls: go1.16
+			path    golang.org/x/tools/gopls
+			mod     golang.org/x/tools/gopls        v0.6.6  h1:GmCsAKZMEb1BD1BTWnQrMyx4FmNThlEsmuFiJbLBXio=
+			dep     github.com/BurntSushi/toml      v0.3.1  h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
+		*/
+		const lines = stdout.split('\n', 3);
+		const goVersion = lines[0].split(/\s+/)[1];
+		const moduleVersion = lines[2].split(/\s+/)[3];
+		return { goVersion, moduleVersion };
+	} catch (e) {
+		outputChannel.appendLine(
+			`Failed to determine the version of ${binPath}. For debugging, run "go version -m ${binPath}"`
+		);
+		// either go version failed or stdout is not in the expected format.
+		return {};
+	}
+}
+
+export async function shouldUpdateTool(tool: Tool, toolPath: string): Promise<boolean> {
+	if (!tool.latestVersion) {
+		return false;
+	}
+
+	const checkForUpdates = getCheckForToolsUpdatesConfig(getGoConfig());
+	if (checkForUpdates === 'off') {
+		return false;
+	}
+	const { moduleVersion } = await inspectGoToolVersion(toolPath);
+	if (!moduleVersion) {
+		return false; // failed to inspect the tool version.
+	}
+	const localVersion = semver.parse(moduleVersion, { includePrerelease: true });
+	return semver.lt(localVersion, tool.latestVersion);
+	// update only if the local version is older than the desired version.
+
+	// TODO(hyangah): figure out when to check if a version newer than
+	// tool.latestVersion is released when checkForUpdates === 'proxy'
+}
diff --git a/src/goTools.ts b/src/goTools.ts
index bc9bc77..d6708eb 100644
--- a/src/goTools.ts
+++ b/src/goTools.ts
@@ -26,6 +26,11 @@
 	// If true, consider prerelease version in preview mode
 	// (nightly & dev)
 	usePrereleaseInPreviewMode?: boolean;
+	// If set, this string will be used when installing the tool
+	// instead of the default 'latest'. It can be used when
+	// we need to pin a tool version (`deadbeaf`) or to use
+	// a dev version available in a branch (e.g. `master`).
+	defaultVersion?: string;
 
 	// latestVersion and latestVersionTimestamp are hardcoded default values
 	// for the last known version of the given tool. We also hardcode values
@@ -433,7 +438,19 @@
 		modulePath: 'github.com/go-delve/delve',
 		replacedByGopls: false,
 		isImportant: true,
-		description: 'Debugging'
+		description: 'Go debugger (Delve)'
+	},
+	'dlv-dap': {
+		name: 'dlv-dap',
+		importPath: 'github.com/go-delve/delve/cmd/dlv',
+		modulePath: 'github.com/go-delve/delve',
+		replacedByGopls: false,
+		isImportant: false,
+		description: 'Go debugger (Delve built for DAP experiment)',
+		defaultVersion: 'master', // Always build from the master.
+		minimumGoVersion: semver.coerce('1.14'), // last 3 versions per delve policy
+		latestVersion: semver.parse('v1.6.1-0.20210224092741-5360c6286949'),
+		latestVersionTimestamp: moment('2021-02-24', 'YYYY-MM-DD')
 	},
 	'fillstruct': {
 		name: 'fillstruct',
diff --git a/test/gopls/update.test.ts b/test/gopls/update.test.ts
index ca6c3af..bf8f095 100644
--- a/test/gopls/update.test.ts
+++ b/test/gopls/update.test.ts
@@ -186,3 +186,62 @@
 		}
 	});
 });
+
+suite.only('version comparison', () => {
+	const tool = getTool('dlv-dap');
+	const latestVersion = tool.latestVersion;
+
+	teardown(() => {
+		sinon.restore();
+	});
+
+	async function testShouldUpdateTool(expected: boolean, moduleVersion?: string) {
+		sinon.stub(goInstallTools, 'inspectGoToolVersion').returns(Promise.resolve({ moduleVersion }));
+		assert.strictEqual(
+			expected,
+			goInstallTools.shouldUpdateTool(tool, '/bin/path/to/dlv-dap'),
+			`hard-coded minimum: ${tool.latestVersion.toString()} vs localVersion: ${moduleVersion}`
+		);
+	}
+
+	test('local delve is old', async () => {
+		testShouldUpdateTool(true, 'v1.6.0');
+	});
+
+	test('local delve is the minimum required version', async () => {
+		testShouldUpdateTool(false, 'v' + latestVersion.toString());
+	});
+
+	test('local delve is newer', async () => {
+		testShouldUpdateTool(false, `v${latestVersion.major}.${latestVersion.minor + 1}.0`);
+	});
+
+	test('local delve is slightly older', async () => {
+		testShouldUpdateTool(
+			true,
+			`v{$latestVersion.major}.${latestVersion.minor}.${latestVersion.patch}-0.20201231000000-5360c6286949`
+		);
+	});
+
+	test('local delve is slightly newer', async () => {
+		testShouldUpdateTool(
+			false,
+			`v{$latestVersion.major}.${latestVersion.minor}.${latestVersion.patch}-0.30211231000000-5360c6286949`
+		);
+	});
+
+	test('local delve version is unknown', async () => {
+		// maybe a wrapper shellscript?
+		testShouldUpdateTool(false, undefined);
+	});
+
+	test('local delve version is non-sense', async () => {
+		// maybe a wrapper shellscript?
+		testShouldUpdateTool(false, 'hello');
+	});
+
+	test('local delve version is non-sense again', async () => {
+		// maybe a wrapper shellscript?
+		testShouldUpdateTool(false, '');
+	});
+});
diff --git a/test/integration/install.test.ts b/test/integration/install.test.ts
index 22ec768..a4375d6 100644
--- a/test/integration/install.test.ts
+++ b/test/integration/install.test.ts
@@ -8,7 +8,7 @@
 import * as assert from 'assert';
 import * as config from '../../src/config';
 import { toolInstallationEnvironment } from '../../src/goEnv';
-import { installTools } from '../../src/goInstallTools';
+import { inspectGoToolVersion, installTools } from '../../src/goInstallTools';
 import { allToolsInformation, getConfiguredTools, getTool, getToolAtVersion } from '../../src/goTools';
 import { getBinPath, getGoVersion, GoVersion, rmdirRecursive } from '../../src/util';
 import { correctBinname } from '../../src/utils/pathUtils';
@@ -149,7 +149,12 @@
 		await runTest(
 			[
 				{ name: 'gopls', versions: ['v0.1.0', 'v1.0.0-pre.1', 'v1.0.0'], wantVersion: 'v1.0.0' },
-				{ name: 'guru', versions: ['v1.0.0'], wantVersion: 'v1.0.0' }
+				{ name: 'guru', versions: ['v1.0.0'], wantVersion: 'v1.0.0' },
+				{
+					name: 'dlv-dap',
+					versions: ['v1.0.0', 'master'],
+					wantVersion: 'v' + getTool('dlv-dap').latestVersion!.toString()
+				}
 			],
 			true
 		);
@@ -173,7 +178,9 @@
 	const proxyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxydir'));
 	for (const tc of testCases) {
 		const tool = getTool(tc.name);
-		const module = tool.importPath;
+		const module = tool.modulePath;
+		const pathInModule =
+			tool.modulePath === tool.importPath ? '' : tool.importPath.slice(tool.modulePath.length + 1) + '/';
 		const versions = tc.versions ?? ['v1.0.0']; // hardcoded for now
 		const dir = path.join(proxyDir, module, '@v');
 		fs.mkdirSync(dir, { recursive: true });
@@ -182,6 +189,16 @@
 		fs.writeFileSync(path.join(dir, 'list'), `${versions.join('\n')}\n`);
 
 		versions.map((version) => {
+			if (version === 'master') {
+				// for dlv-dap that retrieves the version from master
+				const resolvedVersion = tool.latestVersion?.toString() || '1.0.0';
+				version = `v${resolvedVersion}`;
+				fs.writeFileSync(
+					path.join(dir, 'master.info'),
+					`{ "Version": "${version}", "Time": "2020-04-07T14:45:07Z" } `
+				);
+			}
+
 			// Write the go.mod file.
 			fs.writeFileSync(path.join(dir, `${version}.mod`), `module ${module}\n`);
 			// Write the info file.
@@ -193,7 +210,7 @@
 			// Write the zip file.
 			const zip = new AdmZip();
 			const content = 'package main; func main() {};';
-			zip.addFile(`${module}@${version}/main.go`, Buffer.alloc(content.length, content));
+			zip.addFile(`${module}@${version}/${pathInModule}main.go`, Buffer.alloc(content.length, content));
 			zip.writeZip(path.join(dir, `${version}.zip`));
 		});
 	}
@@ -233,25 +250,3 @@
 function fakeGoVersion(version: string) {
 	return new GoVersion('/path/to/go', `go version go${version} windows/amd64`);
 }
-
-// inspectGoToolVersion reads the go version and module version
-// of the given go tool using `go version -m` command.
-async function inspectGoToolVersion(binPath: string): Promise<{ goVersion?: string; moduleVersion?: string }> {
-	const goCmd = getBinPath('go');
-	const execFile = util.promisify(cp.execFile);
-	try {
-		const { stdout } = await execFile(goCmd, ['version', '-m', binPath]);
-		/* The output format will look like this:
-			/Users/hakim/go/bin/gopls: go1.16
-			path    golang.org/x/tools/gopls
-			mod     golang.org/x/tools/gopls        v0.6.6  h1:GmCsAKZMEb1BD1BTWnQrMyx4FmNThlEsmuFiJbLBXio=
-			dep     github.com/BurntSushi/toml      v0.3.1  h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
-		*/
-		const lines = stdout.split('\n', 3);
-		const goVersion = lines[0].split(/\s+/)[1];
-		const moduleVersion = lines[2].split(/\s+/)[3];
-		return { goVersion, moduleVersion };
-	} catch (e) {
-		return {};
-	}
-}