extension/src: support stream $/progress message in log style

If the $/progress's begin message start with style: log, the
all message for this progress token will be streamed to a
terminal.

Because executeCommand extends workDoneProgress, if response
from executeCommand contains progress token, the result will
be streamed to the same terminal.

Gopls side change CL 645695.

For golang/vscode-go#3572

Change-Id: I3ad4db2604423a2285a7c0f57b8a4d66d2c1933a
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/645116
kokoro-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Robert Findley <rfindley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Madeline Kalil <mkalil@google.com>
diff --git a/extension/src/goVulncheck.ts b/extension/src/goVulncheck.ts
index d9d0f74..dddb43f 100644
--- a/extension/src/goVulncheck.ts
+++ b/extension/src/goVulncheck.ts
@@ -7,57 +7,7 @@
 import { URI } from 'vscode-uri';
 import { getGoConfig } from './config';
 import { getWorkspaceFolderPath } from './util';
-
-export interface IVulncheckTerminal {
-	appendLine: (str: string) => void;
-	show: (preserveFocus?: boolean) => void;
-	exit: () => void;
-}
-export class VulncheckTerminal implements IVulncheckTerminal {
-	private term: vscode.Terminal;
-	private writeEmitter = new vscode.EventEmitter<string>();
-
-	// Buffer messages emitted before vscode is ready.  VSC calls pty.open when it is ready.
-	private ptyReady = false;
-	private buf: string[] = [];
-
-	// Constructor function to stub during test.
-	static Open(): IVulncheckTerminal {
-		return new VulncheckTerminal();
-	}
-
-	private constructor() {
-		const pty: vscode.Pseudoterminal = {
-			onDidWrite: this.writeEmitter.event,
-			handleInput: () => this.exit(),
-			open: () => {
-				this.ptyReady = true;
-				this.buf.forEach((l) => this.writeEmitter.fire(l));
-				this.buf = [];
-			},
-			close: () => {}
-		};
-		this.term = vscode.window.createTerminal({ name: 'govulncheck', pty }); // TODO: iconPath
-	}
-
-	appendLine(str: string) {
-		if (!str.endsWith('\n')) {
-			str += '\n';
-		}
-		str = str.replace(/\n/g, '\n\r'); // replaceAll('\n', '\n\r').
-		if (!this.ptyReady) {
-			this.buf.push(str); // print when `open` is called.
-		} else {
-			this.writeEmitter.fire(str);
-		}
-	}
-
-	show(preserveFocus?: boolean) {
-		this.term.show(preserveFocus);
-	}
-
-	exit() {}
-}
+import { IProgressTerminal } from './progressTerminal';
 
 // VulncheckReport is the JSON data type of gopls's vulncheck result.
 export interface VulncheckReport {
@@ -72,7 +22,7 @@
 
 export async function writeVulns(
 	res: VulncheckReport,
-	term: IVulncheckTerminal | undefined,
+	term: IProgressTerminal | undefined,
 	goplsBinPath: string
 ): Promise<void> {
 	if (term === undefined) {
diff --git a/extension/src/language/goLanguageServer.ts b/extension/src/language/goLanguageServer.ts
index 1f32c3c..ae70283 100644
--- a/extension/src/language/goLanguageServer.ts
+++ b/extension/src/language/goLanguageServer.ts
@@ -14,6 +14,7 @@
 import semver = require('semver');
 import util = require('util');
 import vscode = require('vscode');
+import { InitializeParams, LSPAny, LSPObject } from 'vscode-languageserver-protocol';
 import {
 	CancellationToken,
 	CloseAction,
@@ -59,7 +60,8 @@
 import { CommandFactory } from '../commands';
 import { updateLanguageServerIconGoStatusBar } from '../goStatus';
 import { URI } from 'vscode-uri';
-import { IVulncheckTerminal, VulncheckReport, VulncheckTerminal, writeVulns } from '../goVulncheck';
+import { VulncheckReport, writeVulns } from '../goVulncheck';
+import { ActiveProgressTerminals, IProgressTerminal, ProgressTerminal } from '../progressTerminal';
 import { createHash } from 'crypto';
 import { GoExtensionContext } from '../context';
 import { GoDocumentSelector } from '../goMode';
@@ -379,9 +381,23 @@
 		this.onDidChangeVulncheckResultEmitter.dispose();
 		return super.dispose(timeout);
 	}
+
 	public get onDidChangeVulncheckResult(): vscode.Event<VulncheckEvent> {
 		return this.onDidChangeVulncheckResultEmitter.event;
 	}
+
+	protected fillInitializeParams(params: InitializeParams): void {
+		super.fillInitializeParams(params);
+
+		// VSCode-Go honors most client capabilities from the vscode-languageserver-node
+		// library. Experimental capabilities not used by vscode-languageserver-node
+		// can be used for custom communication between vscode-go and gopls.
+		// See https://github.com/microsoft/vscode-languageserver-node/issues/1607
+		const experimental: LSPObject = {
+			progressMessageStyles: ['log']
+		};
+		params.capabilities.experimental = experimental;
+	}
 }
 
 type VulncheckEvent = {
@@ -402,10 +418,12 @@
 	// we want to handle the connection close error case specially. Capture the error
 	// in initializationFailedHandler and handle it in the connectionCloseHandler.
 	let initializationError: ResponseError<InitializeError> | undefined = undefined;
-	let govulncheckTerminal: IVulncheckTerminal | undefined;
 
+	// TODO(hxjiang): deprecate special handling for async call gopls.run_govulncheck.
+	let govulncheckTerminal: IProgressTerminal | undefined;
 	const pendingVulncheckProgressToken = new Map<ProgressToken, any>();
 	const onDidChangeVulncheckResultEmitter = new vscode.EventEmitter<VulncheckEvent>();
+
 	// cfg is captured by closures for later use during error report.
 	const c = new GoLanguageClient(
 		'go', // id
@@ -489,13 +507,34 @@
 				handleWorkDoneProgress: async (token, params, next) => {
 					switch (params.kind) {
 						case 'begin':
+							if (typeof params.message === 'string') {
+								const paragraphs = params.message.split('\n\n', 2);
+								const metadata = paragraphs[0].trim();
+								if (!metadata.startsWith('style: ')) {
+									break;
+								}
+								const style = metadata.substring('style: '.length);
+								if (style === 'log') {
+									const term = ProgressTerminal.Open(params.title, token);
+									if (paragraphs.length > 1) {
+										term.appendLine(paragraphs[1]);
+									}
+									term.show();
+								}
+							}
 							break;
 						case 'report':
+							if (params.message) {
+								ActiveProgressTerminals.get(token)?.appendLine(params.message);
+							}
 							if (pendingVulncheckProgressToken.has(token) && params.message) {
 								govulncheckTerminal?.appendLine(params.message);
 							}
 							break;
 						case 'end':
+							if (params.message) {
+								ActiveProgressTerminals.get(token)?.appendLine(params.message);
+							}
 							if (pendingVulncheckProgressToken.has(token)) {
 								const out = pendingVulncheckProgressToken.get(token);
 								pendingVulncheckProgressToken.delete(token);
@@ -507,7 +546,7 @@
 				},
 				executeCommand: async (command: string, args: any[], next: ExecuteCommandSignature) => {
 					try {
-						if (command === 'gopls.tidy') {
+						if (command === 'gopls.tidy' || command === 'gopls.vulncheck') {
 							await vscode.workspace.saveAll(false);
 						}
 						if (command === 'gopls.run_govulncheck' && args.length && args[0].URI) {
@@ -520,17 +559,35 @@
 							await vscode.workspace.saveAll(false);
 							const uri = args[0].URI ? URI.parse(args[0].URI) : undefined;
 							const dir = uri?.fsPath?.endsWith('.mod') ? path.dirname(uri.fsPath) : uri?.fsPath;
-							govulncheckTerminal = VulncheckTerminal.Open();
+							govulncheckTerminal = ProgressTerminal.Open('govulncheck');
 							govulncheckTerminal.appendLine(`⚡ govulncheck -C ${dir} ./...\n\n`);
 							govulncheckTerminal.show();
 						}
 						const res = await next(command, args);
-						if (command === 'gopls.run_govulncheck') {
-							const progressToken = res.Token;
-							if (progressToken) {
-								pendingVulncheckProgressToken.set(progressToken, args[0]);
+
+						const progressToken = <ProgressToken>res.Token;
+						// The progressToken from executeCommand indicates that
+						// gopls may trigger a related workDoneProgress
+						// notification, either before or after the command
+						// completes.
+						// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#serverInitiatedProgress
+						if (progressToken !== undefined) {
+							switch (command) {
+								case 'gopls.run_govulncheck':
+									pendingVulncheckProgressToken.set(progressToken, args[0]);
+									break;
+								case 'gopls.vulncheck':
+									// Write the vulncheck report to the terminal.
+									if (ActiveProgressTerminals.has(progressToken)) {
+										writeVulns(res.Result, ActiveProgressTerminals.get(progressToken), cfg.path);
+									}
+									break;
+								default:
+									// By default, dump the result to the terminal.
+									ActiveProgressTerminals.get(progressToken)?.appendLine(res.Result);
 							}
 						}
+
 						return res;
 					} catch (e) {
 						// TODO: how to print ${e} reliably???
diff --git a/extension/src/progressTerminal.ts b/extension/src/progressTerminal.ts
new file mode 100644
index 0000000..aaf482a
--- /dev/null
+++ b/extension/src/progressTerminal.ts
@@ -0,0 +1,80 @@
+/*---------------------------------------------------------
+ * Copyright 2025 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import vscode = require('vscode');
+
+import { ProgressToken } from 'vscode-languageclient';
+
+// ActiveProgressTerminals maps progress tokens to their corresponding terminals.
+// Entries are added when terminals are created for workdone progress and
+// deleted when closed by the user, which is interpreted as the user discarding
+// any further information.
+// There is no guarantee a terminal will remain available for the entire
+// duration of a workdone progress notification.
+// Logs can be appended to the terminal even after the workdone progress
+// notification finishes, allowing responses from requests extending
+// WorkDoneProgressOptions to be displayed in the same terminal.
+export const ActiveProgressTerminals = new Map<ProgressToken, IProgressTerminal>();
+
+export interface IProgressTerminal {
+	appendLine: (str: string) => void;
+	show: (preserveFocus?: boolean) => void;
+	exit: () => void;
+}
+export class ProgressTerminal implements IProgressTerminal {
+	private progressToken?: ProgressToken;
+	private term: vscode.Terminal;
+	private writeEmitter = new vscode.EventEmitter<string>();
+
+	// Buffer messages emitted before vscode is ready.  VSC calls pty.open when it is ready.
+	private ptyReady = false;
+	private buf: string[] = [];
+
+	// Constructor function to stub during test.
+	static Open(name = 'progress', token?: ProgressToken): IProgressTerminal {
+		return new ProgressTerminal(name, token);
+	}
+
+	// ProgressTerminal created with token will be managed by map
+	// ActiveProgressTerminals.
+	private constructor(name: string, token?: ProgressToken) {
+		const pty: vscode.Pseudoterminal = {
+			onDidWrite: this.writeEmitter.event,
+			handleInput: () => this.exit(),
+			open: () => {
+				this.ptyReady = true;
+				this.buf.forEach((l) => this.writeEmitter.fire(l));
+				this.buf = [];
+			},
+			close: () => {
+				if (this.progressToken !== undefined) {
+					ActiveProgressTerminals.delete(this.progressToken);
+				}
+			}
+		};
+		this.term = vscode.window.createTerminal({ name: name, pty }); // TODO: iconPath
+		if (token !== undefined) {
+			this.progressToken = token;
+			ActiveProgressTerminals.set(this.progressToken, this);
+		}
+	}
+
+	appendLine(str: string) {
+		if (!str.endsWith('\n')) {
+			str += '\n';
+		}
+		str = str.replace(/\n/g, '\n\r'); // replaceAll('\n', '\n\r').
+		if (!this.ptyReady) {
+			this.buf.push(str); // print when `open` is called.
+		} else {
+			this.writeEmitter.fire(str);
+		}
+	}
+
+	show(preserveFocus?: boolean) {
+		this.term.show(preserveFocus);
+	}
+
+	exit() {}
+}
diff --git a/extension/test/gopls/vulncheck.test.ts b/extension/test/gopls/vulncheck.test.ts
index 7c92ea6..7048b20 100644
--- a/extension/test/gopls/vulncheck.test.ts
+++ b/extension/test/gopls/vulncheck.test.ts
@@ -6,7 +6,7 @@
 import path = require('path');
 import sinon = require('sinon');
 import vscode = require('vscode');
-import { VulncheckTerminal } from '../../src/goVulncheck';
+import { ProgressTerminal } from '../../src/progressTerminal';
 import { ExecuteCommandParams, ExecuteCommandRequest } from 'vscode-languageserver-protocol';
 import { Env, FakeOutputChannel } from './goplsTestEnv.utils';
 import { URI } from 'vscode-uri';
@@ -34,7 +34,7 @@
 		sandbox.stub(config, 'getGoConfig').returns(goConfig);
 		await env.startGopls(undefined, goConfig, fixtureDir);
 
-		sandbox.stub(VulncheckTerminal, 'Open').returns({
+		sandbox.stub(ProgressTerminal, 'Open').returns({
 			appendLine: fakeTerminal.appendLine,
 			show: () => {},
 			exit: () => {}