Repository: stylelint/vscode-stylelint (ts)

Issue:
[Feature]: Add restart server command to extension
### What problem does this feature solve?

There are inevitably times when you need to restart the language server. Currently, you can do this by reloading the window, but that is quite excessive.

### How does this feature solve the problem?

Allows the language server to be restarted via a dedicated command, similar to the ESLint and TypeScript integrations.

### What are some alternatives to this feature?

_No response_

### Code of Conduct

- [X] I agree to follow vscode-stylelint's Code of Conduct

Pull request description:
feat: Add restart server command
Closes #332

Adds "Restart Stylelint Server" command to the extension.

Patch:
diff --git a/package.json b/package.json
index 1a63241..4c0d73c 100644
--- a/package.json
+++ b/package.json
@@ -169,7 +169,12 @@
         "title": "Fix all auto-fixable Problems",
         "category": "Stylelint",
         "command": "stylelint.executeAutofix"
-      }
+      },
+			{
+				"title": "Restart Stylelint Server",
+				"category": "Stylelint",
+				"command": "stylelint.restart"
+			}
     ],
     "jsonValidation": [
       {
diff --git a/src/extension/extension.ts b/src/extension/extension.ts
index 8e350d6..85a62b4 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -41,27 +41,28 @@ export function activate({ subscriptions }: vscode.ExtensionContext): PublicApi
 		},
 	);
 
-	client
-		.onReady()
-		.then(() => {
-			client.onNotification(Notification.DidRegisterCodeActionRequestHandler, () => {
-				api.codeActionReady = true;
-			});
-			client.onNotification(
-				Notification.DidRegisterDocumentFormattingEditProvider,
-				(params: DidRegisterDocumentFormattingEditProviderNotificationParams) => {
-					api.emit(ApiEvent.DidRegisterDocumentFormattingEditProvider, params);
-				},
-			);
-			client.onNotification(Notification.DidResetConfiguration, () => {
-				api.emit(ApiEvent.DidResetConfiguration);
-			});
-		})
-		.catch(async (error) => {
-			await window.showErrorMessage(
-				`Stylelint: ${error instanceof Error ? error.message : String(error)}`,
-			);
+	const readyHandler = (): void => {
+		client.onNotification(Notification.DidRegisterCodeActionRequestHandler, () => {
+			api.codeActionReady = true;
+		});
+		client.onNotification(
+			Notification.DidRegisterDocumentFormattingEditProvider,
+			(params: DidRegisterDocumentFormattingEditProviderNotificationParams) => {
+				api.emit(ApiEvent.DidRegisterDocumentFormattingEditProvider, params);
+			},
+		);
+		client.onNotification(Notification.DidResetConfiguration, () => {
+			api.emit(ApiEvent.DidResetConfiguration);
 		});
+	};
+
+	const errorHandler = async (error: unknown): Promise<void> => {
+		await window.showErrorMessage(
+			`Stylelint: ${error instanceof Error ? error.message : String(error)}`,
+		);
+	};
+
+	client.onReady().then(readyHandler).catch(errorHandler);
 
 	subscriptions.push(
 		// cspell:disable-next-line
@@ -81,11 +82,27 @@ export function activate({ subscriptions }: vscode.ExtensionContext): PublicApi
 				arguments: [textDocument],
 			};
 
-			await client.sendRequest(ExecuteCommandRequest.type, params).then(undefined, async () => {
+			try {
+				await client.sendRequest(ExecuteCommandRequest.type, params);
+			} catch {
 				await window.showErrorMessage(
 					'Failed to apply Stylelint fixes to the document. Please consider opening an issue with steps to reproduce.',
 				);
-			});
+			}
+		}),
+	);
+
+	subscriptions.push(
+		commands.registerCommand('stylelint.restart', async () => {
+			await client.stop();
+			client.start();
+
+			try {
+				await client.onReady();
+				readyHandler();
+			} catch (error: unknown) {
+				await errorHandler(error);
+			}
 		}),
 	);
 


Test patch:
diff --git a/src/extension/__tests__/extension.ts b/src/extension/__tests__/extension.ts
index 1f75822..c80b36b 100644
--- a/src/extension/__tests__/extension.ts
+++ b/src/extension/__tests__/extension.ts
@@ -47,13 +47,11 @@ mockVSCode.workspace = mockWorkspace as unknown as typeof vscode.workspace;
 mockVSCode.commands = mockCommands as unknown as typeof vscode.commands;
 mockVSCode.window = mockWindow as unknown as typeof vscode.window;
 
+const start = jest.fn();
+const stop = jest.fn();
 const onNotification = jest.fn();
-const catchOnReady = jest.fn();
-const catchSendRequest = jest.fn();
-const afterOnReady = jest.fn().mockReturnValue({ catch: catchOnReady });
-const afterSendRequest = jest.fn().mockReturnValue({ catch: catchSendRequest });
-const onReady = jest.fn().mockReturnValue({ then: afterOnReady });
-const sendRequest = jest.fn().mockReturnValue({ then: afterSendRequest });
+const onReady = jest.fn();
+const sendRequest = jest.fn();
 const settingMonitorStart = jest.fn();
 
 const mockExtensionContext = {
@@ -80,14 +78,20 @@ const stripPaths = <T extends unknown[]>(params: T): T => {
 
 describe('Extension entry point', () => {
 	beforeEach(() => {
-		jest.clearAllMocks();
+		jest.resetAllMocks();
 
 		mockVSCode.window.activeTextEditor = undefined;
 
+		start.mockImplementation(async () => undefined);
+		onReady.mockImplementation(async () => undefined);
+		sendRequest.mockImplementation(async () => undefined);
+
 		mockLanguageClient.mockReturnValue({
 			onNotification,
 			onReady,
 			sendRequest,
+			start,
+			stop,
 		} as unknown as LanguageClient);
 
 		mockSettingMonitor.mockReturnValue({
@@ -142,42 +146,39 @@ describe('Extension entry point', () => {
 		expect(subscriptions).toContain(disposable);
 	});
 
-	it('with an active text editor, should send auto-fix commands to the language server', () => {
+	it('with an active text editor, should send auto-fix commands to the language server', async () => {
 		window.activeTextEditor = mockTextEditor;
 
 		activate(mockExtensionContext);
 
-		mockCommands.registerCommand.mock.calls[0][1](undefined);
+		await mockCommands.registerCommand.mock.calls[0][1](undefined);
 
 		expect(sendRequest).toHaveBeenCalledTimes(1);
 		expect(sendRequest.mock.calls[0]).toMatchSnapshot();
-		expect(afterSendRequest).toHaveBeenCalledTimes(1);
-		expect(afterSendRequest.mock.calls[0][0]).toBeUndefined();
 		expect(mockWindow.showErrorMessage).not.toHaveBeenCalled();
 	});
 
-	it('without an active text editor, should not send auto-fix commands to the language server', () => {
+	it('without an active text editor, should not send auto-fix commands to the language server', async () => {
 		window.activeTextEditor = undefined;
 
 		activate(mockExtensionContext);
 
-		mockCommands.registerCommand.mock.calls[0][1](undefined);
+		await mockCommands.registerCommand.mock.calls[0][1](undefined);
 
 		expect(sendRequest).not.toHaveBeenCalled();
-		expect(afterSendRequest).not.toHaveBeenCalled();
 		expect(mockWindow.showErrorMessage).not.toHaveBeenCalled();
 	});
 
-	it('should show an error message if sending the command request fails', () => {
+	it('should show an error message if sending the command request fails', async () => {
 		window.activeTextEditor = mockTextEditor;
 
 		activate(mockExtensionContext);
 
-		mockCommands.registerCommand.mock.calls[0][1](undefined);
-		afterSendRequest.mock.calls[0][1](undefined, new Error());
+		sendRequest.mockRejectedValueOnce(new Error('mock error'));
+
+		await mockCommands.registerCommand.mock.calls[0][1](undefined);
 
 		expect(sendRequest).toHaveBeenCalledTimes(1);
-		expect(afterSendRequest).toHaveBeenCalledTimes(1);
 		expect(mockWindow.showErrorMessage).toHaveBeenCalledTimes(1);
 		expect(mockWindow.showErrorMessage.mock.calls[0]).toMatchInlineSnapshot(`
 		Array [
@@ -186,6 +187,34 @@ describe('Extension entry point', () => {
 	`);
 	});
 
+	it('should register a restart server command', () => {
+		const disposable = { dispose: () => undefined };
+
+		mockCommands.registerCommand.mockReturnValue(disposable);
+
+		activate(mockExtensionContext);
+
+		const { subscriptions } = mockExtensionContext;
+
+		expect(mockCommands.registerCommand).toHaveBeenCalled();
+		expect(mockCommands.registerCommand.mock.calls[1]).toMatchInlineSnapshot(`
+		Array [
+		  "stylelint.restart",
+		  [Function],
+		]
+	`);
+		expect(subscriptions).toContain(disposable);
+	});
+
+	it('should restart the language server', async () => {
+		activate(mockExtensionContext);
+
+		await mockCommands.registerCommand.mock.calls[1][1]();
+
+		expect(stop).toHaveBeenCalled();
+		expect(start).toHaveBeenCalled();
+	});
+
 	it('should monitor settings', () => {
 		const disposable = { dispose: () => undefined };
 
@@ -204,10 +233,10 @@ describe('Extension entry point', () => {
 		expect(subscriptions).toContain(disposable);
 	});
 
-	it('should listen for the DidRegisterCodeActionRequestHandler notification', () => {
+	it('should listen for the DidRegisterCodeActionRequestHandler notification', async () => {
 		activate(mockExtensionContext);
 
-		afterOnReady.mock.calls[0][0]();
+		await new Promise((resolve) => setImmediate(resolve));
 
 		expect(onReady).toHaveBeenCalled();
 		expect(onNotification).toHaveBeenCalled();
@@ -218,16 +247,17 @@ describe('Extension entry point', () => {
 	it('should set codeActionReady to true when the DidRegisterCodeActionRequestHandler notification is received', async () => {
 		const api = activate(mockExtensionContext);
 
-		afterOnReady.mock.calls[0][0]();
+		await new Promise((resolve) => setImmediate(resolve));
+
 		onNotification.mock.calls[0][1]();
 
 		expect(api.codeActionReady).toBe(true);
 	});
 
-	it('should listen for the DidRegisterDocumentFormattingEditProvider notification', () => {
+	it('should listen for the DidRegisterDocumentFormattingEditProvider notification', async () => {
 		activate(mockExtensionContext);
 
-		afterOnReady.mock.calls[0][0]();
+		await new Promise((resolve) => setImmediate(resolve));
 
 		expect(onReady).toHaveBeenCalled();
 		expect(onNotification).toHaveBeenCalled();
@@ -253,16 +283,17 @@ describe('Extension entry point', () => {
 			},
 		};
 
-		afterOnReady.mock.calls[0][0]();
+		await new Promise((resolve) => setImmediate(resolve));
+
 		onNotification.mock.calls[1][1](params);
 
 		await expect(promise).resolves.toStrictEqual(params);
 	});
 
-	it('should listen for the DidResetConfiguration notification', () => {
+	it('should listen for the DidResetConfiguration notification', async () => {
 		activate(mockExtensionContext);
 
-		afterOnReady.mock.calls[0][0]();
+		await new Promise((resolve) => setImmediate(resolve));
 
 		expect(onReady).toHaveBeenCalled();
 		expect(onNotification).toHaveBeenCalled();
@@ -277,17 +308,59 @@ describe('Extension entry point', () => {
 			api.on(ApiEvent.DidResetConfiguration, resolve);
 		});
 
-		afterOnReady.mock.calls[0][0]();
+		await new Promise((resolve) => setImmediate(resolve));
+
 		onNotification.mock.calls[2][1]();
 
 		await expect(promise).resolves.toBeUndefined();
 	});
 
-	it('should show an error message if the DidRegisterDocumentFormattingEditProvider notification fails', async () => {
+	it('should show an error message if registering notifications fails', async () => {
+		onNotification.mockImplementation((): void => {
+			throw new Error('Problem!');
+		});
+
+		activate(mockExtensionContext);
+
+		await new Promise((resolve) => setImmediate(resolve));
+
+		onNotification.mockImplementation((): void => {
+			throw 'String problem!';
+		});
+
+		activate(mockExtensionContext);
+
+		await new Promise((resolve) => setImmediate(resolve));
+
+		expect(mockWindow.showErrorMessage).toHaveBeenCalledTimes(2);
+		expect(mockWindow.showErrorMessage.mock.calls[0]).toMatchInlineSnapshot(`
+		Array [
+		  "Stylelint: Problem!",
+		]
+	`);
+		expect(mockWindow.showErrorMessage.mock.calls[1]).toMatchInlineSnapshot(`
+		Array [
+		  "Stylelint: String problem!",
+		]
+	`);
+	});
+
+	it('should show an error message if restarting the language server fails', async () => {
 		activate(mockExtensionContext);
 
-		await catchOnReady.mock.calls[0][0](new Error('Problem!'));
-		await catchOnReady.mock.calls[0][0]('String problem!');
+		await new Promise((resolve) => setImmediate(resolve));
+
+		onReady.mockImplementation((): void => {
+			throw new Error('Problem!');
+		});
+
+		await mockCommands.registerCommand.mock.calls[1][1]();
+
+		onReady.mockImplementation((): void => {
+			throw 'String problem!';
+		});
+
+		await mockCommands.registerCommand.mock.calls[1][1]();
 
 		expect(mockWindow.showErrorMessage).toHaveBeenCalledTimes(2);
 		expect(mockWindow.showErrorMessage.mock.calls[0]).toMatchInlineSnapshot(`
diff --git a/test/e2e/__tests__/restart.ts b/test/e2e/__tests__/restart.ts
new file mode 100644
index 0000000..e282c73
--- /dev/null
+++ b/test/e2e/__tests__/restart.ts
@@ -0,0 +1,20 @@
+import path from 'path';
+import { commands, languages } from 'vscode';
+
+// Skipped for now because of https://github.com/microsoft/vscode-languageserver-node/issues/723
+// Should work with LSP 3.17 when released
+// eslint-disable-next-line jest/no-disabled-tests
+describe.skip('Restart command', () => {
+	it('should restart the language server', async () => {
+		const { document } = await openDocument(path.resolve(workspaceDir, 'defaults/lint.css'));
+		const diagnostics1 = await waitForDiagnostics(document);
+
+		await commands.executeCommand('stylelint.restart');
+
+		expect(languages.getDiagnostics(document.uri)).toEqual([]);
+
+		const diagnostics2 = await waitForDiagnostics(document);
+
+		expect(diagnostics2).toEqual(diagnostics1);
+	});
+});
diff --git a/test/e2e/setup.ts b/test/e2e/setup.ts
index 433db34..721cecd 100644
--- a/test/e2e/setup.ts
+++ b/test/e2e/setup.ts
@@ -8,10 +8,9 @@ import {
 	workspace,
 	TextEditor,
 	TextDocument,
-	languages,
 	Diagnostic,
 } from 'vscode';
-import type { ApiEvent, ExtensionEvents, PublicApi } from '../../src/extension';
+import type { ExtensionEvents, PublicApi } from '../../src/extension';
 import { getStylelintDiagnostics } from './utils';
 
 Object.defineProperty(global, 'workspaceDir', {