src/goEnvironmentStatus.ts: allow installation of Go versions

This CL is the third in a series which should follow this general
outline:

1. Create status bar item for switching Go binary (not implemented)
2. Create command palette menu for choosing the Go binary
3. Track the currently selected Go binary using workspace context
4. Show versions of Go that are not installed and allow them to be
selected and installed
5. Ensure new integrated terminals use the selected environment
6. Update workspace state to use settings.json instead
7. Detect if Go is not installed and prompt to install it
8. Detect if user has the latest version of Go installed and prompt them
to install it
9. Cache Go paths upon extension initialization for faster menu loading

This CL fetches Go versions from golang.org/dl and presents them as
options in the command palette menu. Upon selection, the go binary is
installed and set in the workspace state

Updates golang/vscode-go#253

Change-Id: Ieb2b407f2149b016fbf88188ff94ee7cefe36671
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/239597
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/src/goEnvironmentStatus.ts b/src/goEnvironmentStatus.ts
index a1650a2..89b98d9 100644
--- a/src/goEnvironmentStatus.ts
+++ b/src/goEnvironmentStatus.ts
@@ -5,19 +5,34 @@
 
 'use strict';
 
+import cp = require('child_process');
 import fs = require('fs-extra');
 import os = require('os');
 import path = require('path');
+import { promisify } from 'util';
 import vscode = require('vscode');
+import WebRequest = require('web-request');
 
+import { toolInstallationEnvironment } from './goEnv';
 import { updateGoVarsFromConfig } from './goInstallTools';
 import { getCurrentGoRoot } from './goPath';
+import { outputChannel } from './goStatus';
 import { getFromWorkspaceState, updateWorkspaceState } from './stateUtils';
-import { getGoVersion } from './util';
+import { getBinPath, getGoVersion } from './util';
 
-interface GoEnvironmentOption {
-	path: string;
-	label: string;
+export class GoEnvironmentOption {
+	public static fromQuickPickItem({ description, label }: vscode.QuickPickItem): GoEnvironmentOption {
+		return new GoEnvironmentOption(description, label);
+	}
+
+	constructor(public binpath: string, public label: string) {}
+
+	public toQuickPickItem(): vscode.QuickPickItem {
+		return {
+			label: this.label,
+			description: this.binpath,
+		};
+	}
 }
 
 // statusbar item for switching the Go environment
@@ -32,9 +47,11 @@
 	}
 	// set Go version and command
 	const version = await getGoVersion();
+	const goOption = new GoEnvironmentOption(version.binaryPath, formatGoVersion(version.format()));
+	await setSelectedGo(goOption);
 
 	hideGoStatusBar();
-	goEnvStatusbarItem.text = formatGoVersion(version.format());
+	goEnvStatusbarItem.text = goOption.label;
 	goEnvStatusbarItem.command = 'go.environment.choose';
 	showGoStatusBar();
 }
@@ -74,65 +91,121 @@
 		return;
 	}
 
-	// get list of Go versions
-	const sdkPath = path.join(os.homedir(), 'sdk');
-	if (!await fs.pathExists(sdkPath)) {
-		vscode.window.showErrorMessage(`SDK path does not exist: ${sdkPath}`);
+	// fetch default go and uninstalled gos
+	let defaultOption: GoEnvironmentOption;
+	let uninstalledOptions: GoEnvironmentOption[];
+	let goSDKOptions: GoEnvironmentOption[];
+	try {
+		[defaultOption, uninstalledOptions, goSDKOptions] = await Promise.all([
+			getDefaultGoOption(),
+			fetchDownloadableGoVersions(),
+			getSDKGoOptions()
+		]);
+	} catch (e) {
+		vscode.window.showErrorMessage(e.message);
 		return;
 	}
-	const subdirs = await fs.readdir(sdkPath);
 
 	// create quick pick items
-	// the dir happens to be the version, which will be used as the label
-	// the path is assembled and used as the description
-	const goSdkOptions: vscode.QuickPickItem[] = subdirs.map((dir: string) => {
-		return {
-			label: dir.replace('go', 'Go '),
-			description: path.join(sdkPath, dir, 'bin', 'go'),
-		};
-	});
-
-	// get default option
-	let defaultOption: vscode.QuickPickItem;
-	try {
-		const defaultGo = await getDefaultGoOption();
-		defaultOption = {
-			label: defaultGo.label,
-			description: defaultGo.path
-		};
-	} catch (err) {
-		vscode.window.showErrorMessage(err.message);
-		return;
-	}
+	const uninstalledQuickPicks = uninstalledOptions.map((op) => op.toQuickPickItem());
+	const defaultQuickPick = defaultOption.toQuickPickItem();
+	const goSDKQuickPicks = goSDKOptions.map((op) => op.toQuickPickItem());
 
 	// dedup options by eliminating duplicate paths (description)
-	const options = [defaultOption, ...goSdkOptions].reduce((opts, nextOption) => {
-		if (opts.find((op) => op.description === nextOption.description)) {
+	const options = [defaultQuickPick, ...goSDKQuickPicks, ...uninstalledQuickPicks].reduce((opts, nextOption) => {
+		if (opts.find((op) => op.description === nextOption.description || op.label === nextOption.label)) {
 			return opts;
 		}
 		return [...opts, nextOption];
 	}, [] as vscode.QuickPickItem[]);
 
-	// show quick pick to select new go version
-	const { label, description } = await vscode.window.showQuickPick<vscode.QuickPickItem>(options);
-	vscode.window.showInformationMessage(`Current GOROOT: ${description}`);
+	// get user's selection, return if none was made
+	const selection = await vscode.window.showQuickPick<vscode.QuickPickItem>(options);
+	if (!selection) {
+		return;
+	}
 
 	// update currently selected go
-	await setSelectedGo({
-		label,
-		path: description,
-	});
+	try {
+		await setSelectedGo(GoEnvironmentOption.fromQuickPickItem(selection));
+		vscode.window.showInformationMessage(`Switched to ${selection.label}`);
+	} catch (e) {
+		vscode.window.showErrorMessage(e.message);
+	}
 }
 
 /**
  * update the selected go path and label in the workspace state
  */
-async function setSelectedGo(selectedGo: GoEnvironmentOption) {
-	// the go-environment state should follow the below format
+export async function setSelectedGo(selectedGo: GoEnvironmentOption) {
+	const execFile = promisify(cp.execFile);
+	// if the selected go version is not installed, install it
+	if (selectedGo.binpath.startsWith('go get')) {
+		// start a loading indicator
+		await vscode.window.withProgress({
+			title: `Downloading ${selectedGo.label}`,
+			location: vscode.ProgressLocation.Notification,
+		}, async () => {
+			outputChannel.show();
+			outputChannel.clear();
+
+			outputChannel.appendLine('Finding Go executable for downloading');
+			const goExecutable = getBinPath('go');
+			if (!goExecutable) {
+				outputChannel.appendLine('Could not find Go executable.');
+				throw new Error('Could not find Go tool.');
+			}
+
+			// use the current go executable to download the new version
+			const env = toolInstallationEnvironment();
+			const [, ...args] = selectedGo.binpath.split(' ');
+			outputChannel.appendLine(`Running ${goExecutable} ${args.join(' ')}`);
+			try {
+				await execFile(goExecutable, args, { env });
+			} catch (getErr) {
+				outputChannel.appendLine(`Error finding Go: ${getErr}`);
+				throw new Error('Could not find Go version.');
+			}
+
+			// run `goX.X download`
+			const newExecutableName = args[1].split('/')[2];
+			const goXExecutable = getBinPath(newExecutableName);
+			outputChannel.appendLine(`Running: ${goXExecutable} download`);
+			try {
+				await execFile(goXExecutable, ['download'], { env });
+			} catch (downloadErr) {
+				outputChannel.appendLine(`Error finishing installation: ${downloadErr}`);
+				throw new Error('Could not download Go version.');
+			}
+
+			outputChannel.appendLine('Finding newly downloaded Go');
+			const sdkPath = path.join(process.env.HOME, 'sdk');
+			if (!await fs.pathExists(sdkPath)) {
+				outputChannel.appendLine(`SDK path does not exist: ${sdkPath}`);
+				throw new Error(`SDK path does not exist: ${sdkPath}`);
+			}
+			const subdirs = await fs.readdir(sdkPath);
+			const dir = subdirs.find((subdir) => subdir === newExecutableName);
+			if (!dir) {
+				outputChannel.appendLine(`Could not install Go to directory: ${dir}`);
+				throw new Error('Could not install Go version.');
+			}
+
+			outputChannel.appendLine('Updating selected Go version.');
+			await updateWorkspaceState('selected-go', new GoEnvironmentOption(
+				path.join(sdkPath, dir, 'bin', 'go'),
+				selectedGo.label
+			));
+			goEnvStatusbarItem.text = selectedGo.label;
+			outputChannel.appendLine('Success!');
+		});
+	} else {
+		await updateWorkspaceState('selected-go', selectedGo);
+		goEnvStatusbarItem.text = selectedGo.label;
+	}
+
 	// TODO: restart language server when the Go binary is switched
 	// TODO: determine if changes to settings.json need to be made
-	await updateWorkspaceState('selected-go', selectedGo);
-	goEnvStatusbarItem.text = selectedGo.label;
 }
 
 /**
@@ -173,6 +246,23 @@
 	}
 }
 
+async function getSDKGoOptions(): Promise<GoEnvironmentOption[]> {
+	// get list of Go versions
+	const sdkPath = path.join(os.homedir(), 'sdk');
+	if (!await fs.pathExists(sdkPath)) {
+		return [];
+	}
+	const subdirs = await fs.readdir(sdkPath);
+	// the dir happens to be the version, which will be used as the label
+	// the path is assembled and used as the description
+	return subdirs.map((dir: string) =>
+		new GoEnvironmentOption(
+			path.join(sdkPath, dir, 'bin', 'go'),
+			dir.replace('go', 'Go '),
+		)
+	);
+}
+
 export async function getDefaultGoOption(): Promise<GoEnvironmentOption> {
 	// make goroot default to go.goroot
 	const goroot = await getActiveGoRoot();
@@ -182,8 +272,40 @@
 
 	// set Go version and command
 	const version = await getGoVersion();
-	return {
-		path: path.join(goroot, 'bin', 'go'),
-		label: formatGoVersion(version.format()),
-	};
+	return new GoEnvironmentOption(
+		path.join(goroot, 'bin', 'go'),
+		formatGoVersion(version.format()),
+	);
+}
+
+/**
+ * make a web request to get versions of Go
+ */
+interface GoVersionWebResult {
+	version: string;
+	stable: boolean;
+	files: {
+		filename: string;
+		os: string;
+		arch: string;
+		version: string;
+		sha256: string;
+		size: number;
+		kind: string;
+	}[];
+}
+async function fetchDownloadableGoVersions(): Promise<GoEnvironmentOption[]> {
+	// fetch information about what Go versions are available to install
+	const webResults = await WebRequest.json<GoVersionWebResult[]>('https://golang.org/dl/?mode=json');
+	if (!webResults) {
+		return [];
+	}
+
+	// turn the web result into GoEnvironmentOption model
+	return webResults.reduce((opts, result: GoVersionWebResult) => {
+		// TODO: allow downloading from different sites
+		const dlPath = `go get golang.org/dl/${result.version}`;
+		const label = result.version.replace('go', 'Go ');
+		return [...opts, new GoEnvironmentOption(dlPath, label)];
+	}, []);
 }
diff --git a/test/integration/statusbar.test.ts b/test/integration/statusbar.test.ts
index 42d166e..e025dcd 100644
--- a/test/integration/statusbar.test.ts
+++ b/test/integration/statusbar.test.ts
@@ -5,15 +5,23 @@
  *--------------------------------------------------------*/
 
 import * as assert from 'assert';
-import cp = require('child_process');
-import fs = require('fs');
+import * as cp from 'child_process';
+import * as fs from 'fs-extra';
 import { describe, it } from 'mocha';
-import os = require('os');
-import path = require('path');
-import sinon = require('sinon');
-import util = require('util');
+import * as os from 'os';
+import * as path from 'path';
+import * as sinon from 'sinon';
+import * as util from 'util';
 import { WorkspaceConfiguration } from 'vscode';
-import { disposeGoStatusBar, formatGoVersion, getGoEnvironmentStatusbarItem } from '../../src/goEnvironmentStatus';
+
+import {
+	disposeGoStatusBar,
+	formatGoVersion,
+	getGoEnvironmentStatusbarItem,
+	getSelectedGo,
+	GoEnvironmentOption,
+	setSelectedGo,
+} from '../../src/goEnvironmentStatus';
 import { updateGoVarsFromConfig } from '../../src/goInstallTools';
 import { getCurrentGoRoot } from '../../src/goPath';
 import ourutil = require('../../src/util');
@@ -42,6 +50,46 @@
 	});
 });
 
+describe('#setSelectedGo()', function () {
+	this.timeout(20000);
+	let goOption: GoEnvironmentOption;
+
+	this.beforeEach(async () => {
+		goOption = await getSelectedGo();
+	});
+	this.afterEach(async () => {
+		await setSelectedGo(goOption);
+	});
+
+	it('should update the selected Go in workspace context', async () => {
+		const testOption = new GoEnvironmentOption('testpath', 'testlabel');
+		await setSelectedGo(testOption);
+		const setOption = await getSelectedGo();
+		assert.ok(setOption.label === 'testlabel' && setOption.binpath === 'testpath', 'Selected go was not set properly');
+	});
+
+	it('should download an uninstalled version of Go', async () => {
+		if (!!process.env['VSCODEGO_BEFORE_RELEASE_TESTS']) {
+			return;
+		}
+
+		// setup tmp home directory for sdk installation
+		const envCache = Object.assign({}, process.env);
+		process.env.HOME = os.tmpdir();
+
+		// set selected go as a version to download
+		const option = new GoEnvironmentOption('go get golang.org/dl/go1.13.12', 'Go 1.13.12');
+		await setSelectedGo(option);
+
+		// the temp sdk directory should now contain go1.13.12
+		const subdirs = await fs.readdir(path.join(os.tmpdir(), 'sdk'));
+		assert.ok(subdirs.includes('go1.13.12'), 'Go 1.13.12 was not installed');
+
+		// cleanup
+		process.env = envCache;
+	});
+});
+
 describe('#updateGoVarsFromConfig()', function () {
 	this.timeout(10000);