goLanguageServer: add logic to prompt users to opt back into gopls

Users who have explicitly disabled gopls may have done so a long time
ago when it wasn't yet stabilized. We should occasionally prompt them
to opt in now that it's on by default.

Set up a prompt that will show up until the user makes a selection. If
they opt out, direct them to a survey to share feedback.

This works per-workspace, so users will be re-prompted if they open a
different workspace with gopls disabled.

I used MessageItems in the vscode.window.showInformationMessage
function because sinon refused to stub any other signature.

Change-Id: Id46e88ab1e0a44740e777588288c27ddb4da93a6
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/289090
Trust: Rebecca Stambler <rstambler@golang.org>
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/src/goLanguageServer.ts b/src/goLanguageServer.ts
index 6a62d9e..50d24fa 100644
--- a/src/goLanguageServer.ts
+++ b/src/goLanguageServer.ts
@@ -20,14 +20,12 @@
 	CompletionItemKind,
 	ConfigurationParams,
 	ConfigurationRequest,
-	DocumentSelector,
 	ErrorAction,
 	HandleDiagnosticsSignature,
 	InitializeError,
 	Message,
 	ProvideCodeLensesSignature,
 	ProvideCompletionItemsSignature,
-	ProvideDocumentLinksSignature,
 	ResponseError,
 	RevealOutputChannelOn
 } from 'vscode-languageclient';
@@ -56,7 +54,7 @@
 import { GoWorkspaceSymbolProvider } from './goSymbol';
 import { getTool, Tool } from './goTools';
 import { GoTypeDefinitionProvider } from './goTypeDefinition';
-import { getFromGlobalState, updateGlobalState } from './stateUtils';
+import { getFromGlobalState, getFromWorkspaceState, getWorkspaceState, updateGlobalState, updateWorkspaceState } from './stateUtils';
 import {
 	getBinPath,
 	getCheckForToolsUpdatesConfig,
@@ -130,15 +128,17 @@
 	const goConfig = getGoConfig();
 	const cfg = buildLanguageServerConfig(goConfig);
 
+	// We have some extra prompts for gopls users and for people who have opted
+	// out of gopls.
+	if (activation) {
+		scheduleGoplsSuggestions();
+	}
+
 	// If the language server is gopls, we enable a few additional features.
 	// These include prompting for updates and surveys.
 	if (cfg.serverName === 'gopls') {
 		const tool = getTool(cfg.serverName);
 		if (tool) {
-			if (activation) {
-				scheduleGoplsSuggestions(tool);
-			}
-
 			// If the language server is turned on because it is enabled by default,
 			// make sure that the user is using a new enough version.
 			if (cfg.enabled && languageServerUsingDefault(goConfig)) {
@@ -167,14 +167,13 @@
 // suggestions. We check user's gopls versions once per day to prompt users to
 // update to the latest version. We also check if we should prompt users to
 // fill out the survey.
-function scheduleGoplsSuggestions(tool: Tool) {
-	const update = async () => {
-		setTimeout(update, timeDay);
-
-		const cfg = buildLanguageServerConfig(getGoConfig());
-		if (!cfg.enabled) {
-			return;
-		}
+function scheduleGoplsSuggestions() {
+	// Some helper functions.
+	const usingGopls = (cfg: LanguageServerConfig): boolean => {
+		return cfg.enabled && cfg.serverName === 'gopls';
+	};
+	const installGopls = async (cfg: LanguageServerConfig) => {
+		const tool = getTool('gopls');
 		const versionToUpdate = await shouldUpdateLanguageServer(tool, cfg);
 		if (!versionToUpdate) {
 			return;
@@ -190,11 +189,40 @@
 			promptForUpdatingTool(tool.name, versionToUpdate);
 		}
 	};
+	const update = async () => {
+		setTimeout(update, timeDay);
+
+		let cfg = buildLanguageServerConfig(getGoConfig());
+		if (!usingGopls(cfg)) {
+			// This shouldn't happen, but if the user has a non-gopls language
+			// server enabled, we shouldn't prompt them to change.
+			if (cfg.serverName !== '') {
+				return;
+			}
+			// Check if the configuration is set in the workspace.
+			const useLanguageServer = getGoConfig().inspect('useLanguageServer');
+			let workspace: boolean;
+			if (useLanguageServer.workspaceFolderValue === false || useLanguageServer.workspaceValue === false) {
+				workspace = true;
+			}
+			// Prompt the user to enable gopls and record what actions they took.
+			let optOutCfg = getGoplsOptOutConfig(workspace);
+			optOutCfg = await promptAboutGoplsOptOut(optOutCfg);
+			flushGoplsOptOutConfig(optOutCfg, workspace);
+			// Check if the language server has now been enabled, and if so,
+			// it will be installed below.
+			cfg = buildLanguageServerConfig(getGoConfig());
+			if (!cfg.enabled) {
+				return;
+			}
+		}
+		await installGopls(cfg);
+	};
 	const survey = async () => {
 		setTimeout(survey, timeDay);
 
 		const cfg = buildLanguageServerConfig(getGoConfig());
-		if (!cfg.enabled) {
+		if (!usingGopls(cfg)) {
 			return;
 		}
 		maybePromptForGoplsSurvey();
@@ -204,6 +232,76 @@
 	setTimeout(survey, 30 * timeMinute);
 }
 
+export interface GoplsOptOutConfig {
+	prompt?: boolean;
+	lastDatePrompted?: Date;
+}
+
+const goplsOptOutConfigKey = 'goplsOptOutConfig';
+
+function getGoplsOptOutConfig(workspace: boolean): GoplsOptOutConfig {
+	return getStateConfig(goplsOptOutConfigKey, workspace) as GoplsOptOutConfig;
+}
+
+function flushGoplsOptOutConfig(cfg: GoplsOptOutConfig, workspace: boolean) {
+	if (workspace) {
+		updateWorkspaceState(goplsOptOutConfigKey, JSON.stringify(cfg));
+	}
+	updateGlobalState(goplsOptOutConfigKey, JSON.stringify(cfg));
+}
+
+export async function promptAboutGoplsOptOut(cfg: GoplsOptOutConfig): Promise<GoplsOptOutConfig> {
+	if (cfg.prompt === false) {
+		return cfg;
+	}
+	// Prompt the user ~once a month.
+	if (cfg.lastDatePrompted && daysBetween(new Date(), cfg.lastDatePrompted) < 30) {
+		return cfg;
+	}
+	cfg.lastDatePrompted = new Date();
+	const selected = await vscode.window.showInformationMessage(`We noticed that you have disabled the language server.
+It has [stabilized](https://blog.golang.org/gopls-vscode-go) and is now enabled by default in this extension.
+Would you like to enable it now?`, { title: 'Enable' }, { title: 'Not now' }, { title: 'Never' });
+	if (!selected) {
+		return cfg;
+	}
+	switch (selected.title) {
+		case 'Enable':
+			// Change the user's Go configuration to enable the language server.
+			// Remove the setting entirely, since it's on by default now.
+			const goConfig = getGoConfig();
+			await goConfig.update('useLanguageServer', undefined, vscode.ConfigurationTarget.Global);
+			if (goConfig.inspect('useLanguageServer').workspaceValue === false) {
+				await goConfig.update('useLanguageServer', undefined, vscode.ConfigurationTarget.Workspace);
+			}
+			if (goConfig.inspect('useLanguageServer').workspaceFolderValue === false) {
+				await goConfig.update('useLanguageServer', undefined, vscode.ConfigurationTarget.WorkspaceFolder);
+			}
+
+			cfg.prompt = false;
+			break;
+		case 'Not now':
+			cfg.prompt = true;
+			break;
+		case 'Never':
+			cfg.prompt = false;
+			await promptForGoplsOptOutSurvey();
+			break;
+	}
+	return cfg;
+}
+
+async function promptForGoplsOptOutSurvey() {
+	const selected = await vscode.window.showInformationMessage(`No problem. Would you be willing to tell us why you have opted out of the language server?`, { title: 'Yes' }, { title: 'No' });
+	switch (selected.title) {
+		case 'Yes':
+			await vscode.env.openExternal(vscode.Uri.parse(`https://forms.gle/hwC8CncV7Cyc2yBN6`));
+			break;
+		case 'No':
+			break;
+	}
+}
+
 async function startLanguageServer(ctx: vscode.ExtensionContext, config: LanguageServerConfig): Promise<boolean> {
 	// If the client has already been started, make sure to clear existing
 	// diagnostics and stop it.
@@ -987,6 +1085,9 @@
 	if (cfg.version !== '') {
 		return cfg.version;
 	}
+	if (cfg.path === '') {
+		return null;
+	}
 	const execFile = util.promisify(cp.execFile);
 	let output: any;
 	try {
@@ -1222,8 +1323,29 @@
 
 export const goplsSurveyConfig = 'goplsSurveyConfig';
 
-function getSurveyConfig(surveyConfigKey = goplsSurveyConfig): SurveyConfig {
-	const saved = getFromGlobalState(surveyConfigKey);
+function getSurveyConfig(): SurveyConfig {
+	return getStateConfig(goplsSurveyConfig) as SurveyConfig;
+}
+
+export function resetSurveyConfig() {
+	flushSurveyConfig(null);
+}
+
+function flushSurveyConfig(cfg: SurveyConfig) {
+	if (cfg) {
+		updateGlobalState(goplsSurveyConfig, JSON.stringify(cfg));
+	} else {
+		updateGlobalState(goplsSurveyConfig, null);  // reset
+	}
+}
+
+function getStateConfig(globalStateKey: string, workspace?: boolean): any {
+	let saved: any;
+	if (workspace === true) {
+		saved = getFromWorkspaceState(globalStateKey);
+	} else {
+		saved = getFromGlobalState(globalStateKey);
+	}
 	if (saved === undefined) {
 		return {};
 	}
@@ -1260,18 +1382,6 @@
 	}
 }
 
-export function resetSurveyConfig() {
-	flushSurveyConfig(null);
-}
-
-function flushSurveyConfig(cfg: SurveyConfig) {
-	if (cfg) {
-		updateGlobalState(goplsSurveyConfig, JSON.stringify(cfg));
-	} else {
-		updateGlobalState(goplsSurveyConfig, null);  // reset
-	}
-}
-
 // errorKind refers to the different possible kinds of gopls errors.
 enum errorKind {
 	initializationFailure,
@@ -1332,7 +1442,7 @@
 			selected = await vscode.window.showInformationMessage(`The extension was unable to start the language server.
 You may have an invalid value in your "go.languageServerFlags" setting.
 It is currently set to [${languageServerFlags}]. Please correct the setting by navigating to Preferences -> Settings.`,
-'Open settings', 'I need more help.');
+				'Open settings', 'I need more help.');
 			switch (selected) {
 				case 'Open settings':
 					await vscode.commands.executeCommand('workbench.action.openSettings', 'go.languageServerFlags');
diff --git a/src/goModules.ts b/src/goModules.ts
index 902e7af..8ae79c8 100644
--- a/src/goModules.ts
+++ b/src/goModules.ts
@@ -73,16 +73,9 @@
 			);
 		}
 
-		if (goConfig['useLanguageServer'] === false) {
-			const promptMsg = 'For better performance using Go modules, you can try the experimental Go language server, gopls.';
-			promptToUpdateToolForModules('gopls', promptMsg, goConfig)
-				.then((choseToUpdate) => {
-					if (choseToUpdate || goConfig['formatTool'] !== 'goreturns') {
-						return;
-					}
-					const promptFormatToolMsg = `The goreturns tool does not support Go modules. Please update the "formatTool" setting to "goimports".`;
-					promptToUpdateToolForModules('switchFormatToolToGoimports', promptFormatToolMsg, goConfig);
-				});
+		if (goConfig['useLanguageServer'] === false && goConfig['formatTool'] !== 'goreturns') {
+			const promptFormatToolMsg = `The goreturns tool does not support Go modules. Please update the "formatTool" setting to "goimports".`;
+			promptToUpdateToolForModules('switchFormatToolToGoimports', promptFormatToolMsg, goConfig);
 		}
 	}
 	packagePathToGoModPathMap[pkgPath] = goModEnvResult;
diff --git a/test/gopls/survey.test.ts b/test/gopls/survey.test.ts
index 2f04128..3e667db 100644
--- a/test/gopls/survey.test.ts
+++ b/test/gopls/survey.test.ts
@@ -5,7 +5,8 @@
 
 import * as assert from 'assert';
 import sinon = require('sinon');
-import { shouldPromptForGoplsSurvey, SurveyConfig } from '../../src/goLanguageServer';
+import vscode = require('vscode');
+import { GoplsOptOutConfig, promptAboutGoplsOptOut, shouldPromptForGoplsSurvey, SurveyConfig } from '../../src/goLanguageServer';
 
 suite('gopls survey tests', () => {
 	test('prompt for survey', () => {
@@ -82,3 +83,37 @@
 		});
 	});
 });
+
+suite('gopls opt out', () => {
+	let sandbox: sinon.SinonSandbox;
+
+	setup(() => {
+		sandbox = sinon.createSandbox();
+	});
+
+	teardown(() => {
+		sandbox.restore();
+	});
+
+	const testCases: [GoplsOptOutConfig, string, number][] = [
+		// No saved config, different choices in the first dialog box.
+		[{}, 'Enable', 1],
+		[{}, 'Not now', 1],
+		[{}, 'Never', 2],
+		// // Saved config, doesn't matter what the user chooses.
+		[{ prompt: false, }, '', 0],
+		[{ prompt: false, lastDatePrompted: new Date() }, '', 0],
+		[{ prompt: true, }, '', 1],
+		[{ prompt: true, lastDatePrompted: new Date() }, '', 0],
+	];
+
+	testCases.map(async ([testConfig, choice, wantCount], i) => {
+		test(`opt out: ${i}`, async () => {
+			const stub = sandbox.stub(vscode.window, 'showInformationMessage').resolves({ title: choice });
+
+			await promptAboutGoplsOptOut(testConfig);
+			assert.strictEqual(stub.callCount, wantCount);
+
+		});
+	});
+});