Repository: stylelint/vscode-stylelint
Pull request #472 (2023-10-31 10:10:05)

v1.3.0
<!-- Each pull request must be associated with an open issue unless it's a documentation fix. If a corresponding issue does not exist, please create one so we can discuss the change first. -->

<!-- Please answer the following. We close pull requests that don't. -->

> Which issue, if any, is this issue related to?

Related to #437.

> Is there anything in the PR that needs further explanation?

This PR merges changes made in the v1.3 milestone into main.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6a3fce..c72009c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
 
 ## [Unreleased]
 
-## [1.2.4](https://github.com/stylelint/vscode-stylelint/compare/v1.2.3...v1.3.0) (2023-02-14)
+### Added
+
+- Language server can now be restarted with the "Restart Stylelint Server" command ([#339](https://github.com/stylelint/vscode-stylelint/pull/339)).
+- The warning shown for unsupported versions of Stylelint will now be shown in any workspace with a locally installed copy of Stylelint, regardless of the file type of the open file ([#340](https://github.com/stylelint/vscode-stylelint/pull/340)).
+- Language server can now be disposed with `server.dispose()` and is disposed when receiving the shutdown LSP notification ([#326](https://github.com/stylelint/vscode-stylelint/pull/326)).
+- Add support for range warnings ([#358](https://github.com/stylelint/vscode-stylelint/pull/358)).
+
+### Changed
+
+- A custom notification used for testing in VS Code sent by the language server now uses the `stylelint` namespace instead of `textDocument` ([3b845a2](https://github.com/stylelint/vscode-stylelint/commit/3b845a2)).
+
+### Fixed
+
+- Files are now re-linted when a Stylelint configuration file is changed in the workspace ([#356](https://github.com/stylelint/vscode-stylelint/pull/356)).
+
+## [1.2.4](https://github.com/stylelint/vscode-stylelint/compare/v1.2.3...v1.2.4) (2023-02-14)
 
 ### Added
 
diff --git a/package.json b/package.json
index 32c4f4e..5802b4d 100644
--- a/package.json
+++ b/package.json
@@ -178,7 +178,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..489e2f7 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -34,34 +34,36 @@ export function activate({ subscriptions }: vscode.ExtensionContext): PublicApi
 			documentSelector: [{ scheme: 'file' }, { scheme: 'untitled' }],
 			diagnosticCollectionName: 'Stylelint',
 			synchronize: {
-				fileEvents: workspace.createFileSystemWatcher(
-					'**/{.stylelintrc{,.js,.json,.yaml,.yml},stylelint.config.js,.stylelintignore}',
-				),
+				fileEvents: [
+					workspace.createFileSystemWatcher('**/.stylelintrc{,.js,.json,.yaml,.yml}'),
+					workspace.createFileSystemWatcher('**/{stylelint.config.js,.stylelintignore}'),
+				],
 			},
 		},
 	);
 
-	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 +83,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);
+			}
 		}),
 	);
 
diff --git a/src/extension/start-server.ts b/src/extension/start-server.ts
index bf51abc..b65f8fe 100644
--- a/src/extension/start-server.ts
+++ b/src/extension/start-server.ts
@@ -1,44 +1,15 @@
 import path from 'path';
 import { createConnection, ProposedFeatures } from 'vscode-languageserver/node';
-import winston from 'winston';
-import { StylelintLanguageServer, modules } from '../server';
+import { StylelintLanguageServer, modules, createLogger } from '../server';
 
 const connection = createConnection(ProposedFeatures.all);
 
-import { ErrorFormatter, LanguageServerTransport, LanguageServerFormatter } from '../utils/logging';
-
 const { NODE_ENV } = process.env;
 
-const level = NODE_ENV === 'development' ? 'debug' : 'info';
-
-const transports: winston.transport[] = [
-	new LanguageServerTransport({
-		connection,
-		format: winston.format.combine(
-			new ErrorFormatter(),
-			new LanguageServerFormatter({
-				connection,
-				preferredKeyOrder: ['module', 'uri', 'command'],
-			}),
-		),
-	}),
-];
-
-if (level === 'debug') {
-	transports.push(
-		new winston.transports.File({
-			filename: path.join(__dirname, '../stylelint-language-server.log'),
-			level,
-			format: winston.format.combine(
-				new ErrorFormatter(),
-				winston.format.timestamp(),
-				winston.format.json(),
-			),
-		}),
-	);
-}
-
-const logger = winston.createLogger({ level, transports });
+const logger =
+	NODE_ENV === 'development'
+		? createLogger(connection, 'debug', path.join(__dirname, '../stylelint-language-server.log'))
+		: createLogger(connection, 'info');
 
 const server = new StylelintLanguageServer({
 	connection,
diff --git a/src/server/create-logger.ts b/src/server/create-logger.ts
new file mode 100644
index 0000000..49c3b1b
--- /dev/null
+++ b/src/server/create-logger.ts
@@ -0,0 +1,52 @@
+import winston from 'winston';
+import { Connection } from 'vscode-languageserver';
+import { ErrorFormatter, LanguageServerFormatter, LanguageServerTransport } from '../utils/logging';
+
+/**
+ * Returns a Winston logger configured for the language server.
+ * @param connection The language server connection.
+ * @param level The log level. Defaults to `info`.
+ * @param logPath If provided, adds a file transport with the given path.
+ */
+export function createLogger(
+	connection: Connection,
+	level: 'error' | 'warn' | 'info' | 'debug' = 'info',
+	logPath?: string,
+): winston.Logger {
+	const transports: winston.transport[] = [
+		new LanguageServerTransport({
+			connection,
+			format: winston.format.combine(
+				new ErrorFormatter(),
+				new LanguageServerFormatter({
+					connection,
+					preferredKeyOrder: ['module', 'uri', 'command'],
+				}),
+			),
+		}),
+	];
+
+	if (logPath) {
+		transports.push(
+			new winston.transports.File({
+				filename: logPath,
+				format: winston.format.combine(
+					new ErrorFormatter(),
+					winston.format.timestamp(),
+					winston.format.json(),
+				),
+			}),
+		);
+	}
+
+	return winston.createLogger({
+		level,
+		transports,
+		levels: {
+			error: 0,
+			warn: 1,
+			info: 2,
+			debug: 3,
+		},
+	});
+}
diff --git a/src/server/index.ts b/src/server/index.ts
index 204f34e..dea685e 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1,3 +1,4 @@
+export * from './create-logger';
 export * as modules from './modules';
 export * from './server';
 export * from './types';
diff --git a/src/server/modules/auto-fix.ts b/src/server/modules/auto-fix.ts
index fb9cf7d..28fc4c6 100644
--- a/src/server/modules/auto-fix.ts
+++ b/src/server/modules/auto-fix.ts
@@ -22,6 +22,11 @@ export class AutoFixModule implements LanguageServerModule {
 	 */
 	#logger: winston.Logger | undefined;
 
+	/**
+	 * Disposables for handlers.
+	 */
+	#disposables: LSP.Disposable[] = [];
+
 	constructor({ context, logger }: LanguageServerModuleConstructorParameters) {
 		this.#context = context;
 		this.#logger = logger;
@@ -43,59 +48,66 @@ export class AutoFixModule implements LanguageServerModule {
 		};
 	}
 
+	dispose(): void {
+		this.#disposables.forEach((disposable) => disposable.dispose());
+		this.#disposables.length = 0;
+	}
+
 	onDidRegisterHandlers(): void {
 		this.#logger?.debug('Registering onExecuteCommand handler');
 
-		this.#context.commands.on(CommandId.ApplyAutoFix, async ({ arguments: args }) => {
-			if (!args) {
-				return {};
-			}
-
-			const identifier = args[0] as { version: number; uri: string };
-			const uri = identifier.uri;
-			const document = this.#context.documents.get(uri);
-
-			if (!document || !(await this.#shouldAutoFix(document))) {
-				if (this.#logger?.isDebugEnabled()) {
-					if (!document) {
-						this.#logger.debug('Unknown document, ignoring', { uri });
-					} else {
-						this.#logger.debug('Document should not be auto-fixed, ignoring', {
-							uri,
-							language: document.languageId,
-						});
-					}
+		this.#disposables.push(
+			this.#context.commands.on(CommandId.ApplyAutoFix, async ({ arguments: args }) => {
+				if (!args) {
+					return {};
 				}
 
-				return {};
-			}
+				const identifier = args[0] as { version: number; uri: string };
+				const uri = identifier.uri;
+				const document = this.#context.documents.get(uri);
+
+				if (!document || !(await this.#shouldAutoFix(document))) {
+					if (this.#logger?.isDebugEnabled()) {
+						if (!document) {
+							this.#logger.debug('Unknown document, ignoring', { uri });
+						} else {
+							this.#logger.debug('Document should not be auto-fixed, ignoring', {
+								uri,
+								language: document.languageId,
+							});
+						}
+					}
 
-			if (identifier.version !== document.version) {
-				this.#logger?.debug('Document has been modified, ignoring', { uri });
+					return {};
+				}
 
-				return {};
-			}
+				if (identifier.version !== document.version) {
+					this.#logger?.debug('Document has been modified, ignoring', { uri });
 
-			const workspaceChange = new WorkspaceChange();
-			const textChange = workspaceChange.getTextEditChange(identifier);
+					return {};
+				}
 
-			const edits = await this.#context.getFixes(document);
+				const workspaceChange = new WorkspaceChange();
+				const textChange = workspaceChange.getTextEditChange(identifier);
 
-			edits.forEach((edit) => textChange.add(edit));
+				const edits = await this.#context.getFixes(document);
 
-			this.#logger?.debug('Applying fixes', { uri, edits });
+				edits.forEach((edit) => textChange.add(edit));
 
-			try {
-				const response = await this.#context.connection.workspace.applyEdit(workspaceChange.edit);
+				this.#logger?.debug('Applying fixes', { uri, edits });
 
-				if (!response.applied) {
-					this.#logger?.debug('Failed to apply fixes', { uri, response });
+				try {
+					const response = await this.#context.connection.workspace.applyEdit(workspaceChange.edit);
+
+					if (!response.applied) {
+						this.#logger?.debug('Failed to apply fixes', { uri, response });
+					}
+				} catch (error) {
+					this.#logger?.debug('Failed to apply fixes', { uri, error });
 				}
-			} catch (error) {
-				this.#logger?.debug('Failed to apply fixes', { uri, error });
-			}
 
-			return {};
-		});
+				return {};
+			}),
+		);
 	}
 }
diff --git a/src/server/modules/code-action.ts b/src/server/modules/code-action.ts
index 8ee0290..bb7bceb 100644
--- a/src/server/modules/code-action.ts
+++ b/src/server/modules/code-action.ts
@@ -13,7 +13,6 @@ import type {
 	LanguageServerModuleConstructorParameters,
 	LanguageServerModule,
 } from '../types';
-import { InitializedNotification } from 'vscode-languageserver-protocol';
 
 export class CodeActionModule implements LanguageServerModule {
 	static id = 'code-action';
@@ -28,6 +27,11 @@ export class CodeActionModule implements LanguageServerModule {
 	 */
 	#logger: winston.Logger | undefined;
 
+	/**
+	 * Disposables for notification and command handlers.
+	 */
+	#disposables: LSP.Disposable[] = [];
+
 	constructor({ context, logger }: LanguageServerModuleConstructorParameters) {
 		this.#context = context;
 		this.#logger = logger;
@@ -39,6 +43,12 @@ export class CodeActionModule implements LanguageServerModule {
 		return options.validate.includes(document.languageId);
 	}
 
+	dispose(): void {
+		this.#disposables.forEach((disposable) => disposable.dispose());
+		this.#disposables.length = 0;
+		this.#context.connection.onCodeAction(() => undefined);
+	}
+
 	onInitialize(): Partial<LSP.InitializeResult> {
 		return {
 			capabilities: {
@@ -87,39 +97,43 @@ export class CodeActionModule implements LanguageServerModule {
 
 		this.#logger?.debug('onCodeAction handler registered');
 
-		this.#context.commands.on(CommandId.OpenRuleDoc, async ({ arguments: args }) => {
-			const params = args?.[0] as { uri: string } | undefined;
+		this.#disposables.push(
+			this.#context.commands.on(CommandId.OpenRuleDoc, async ({ arguments: args }) => {
+				const params = args?.[0] as { uri: string } | undefined;
 
-			if (!params) {
-				this.#logger?.debug('No URL provided, ignoring command request');
+				if (!params) {
+					this.#logger?.debug('No URL provided, ignoring command request');
 
-				return {};
-			}
+					return {};
+				}
 
-			const { uri } = params;
+				const { uri } = params;
 
-			this.#logger?.debug('Opening rule documentation', { uri });
+				this.#logger?.debug('Opening rule documentation', { uri });
 
-			// Open URL in browser
-			const showURIResponse = await this.#context.connection.window.showDocument({
-				uri,
-				external: true,
-			});
+				// Open URL in browser
+				const showURIResponse = await this.#context.connection.window.showDocument({
+					uri,
+					external: true,
+				});
 
-			if (!showURIResponse.success) {
-				this.#logger?.warn('Failed to open rule documentation', { uri });
+				if (!showURIResponse.success) {
+					this.#logger?.warn('Failed to open rule documentation', { uri });
 
-				return new LSP.ResponseError(
-					LSP.ErrorCodes.InternalError,
-					'Failed to open rule documentation',
-				);
-			}
+					return new LSP.ResponseError(
+						LSP.ErrorCodes.InternalError,
+						'Failed to open rule documentation',
+					);
+				}
 
-			return {};
-		});
+				return {};
+			}),
+		);
 
-		this.#context.notifications.on(InitializedNotification.type, () =>
-			this.#context.connection.sendNotification(Notification.DidRegisterCodeActionRequestHandler),
+		this.#disposables.push(
+			this.#context.notifications.on(LSP.InitializedNotification.type, () =>
+				this.#context.connection.sendNotification(Notification.DidRegisterCodeActionRequestHandler),
+			),
 		);
 	}
 
diff --git a/src/server/modules/completion.ts b/src/server/modules/completion.ts
index 6fb1312..525844f 100644
--- a/src/server/modules/completion.ts
+++ b/src/server/modules/completion.ts
@@ -30,6 +30,10 @@ export class CompletionModule implements LanguageServerModule {
 		this.#logger = logger;
 	}
 
+	dispose(): void {
+		this.#context.connection.onCompletion(() => undefined);
+	}
+
 	onInitialize(): Partial<LSP.InitializeResult> {
 		return {
 			capabilities: {
diff --git a/src/server/modules/formatter.ts b/src/server/modules/formatter.ts
index 5dc0c53..42a1569 100644
--- a/src/server/modules/formatter.ts
+++ b/src/server/modules/formatter.ts
@@ -35,6 +35,11 @@ export class FormatterModule implements LanguageServerModule {
 	 */
 	#registrations = new Map<string, Promise<LSP.Disposable>>();
 
+	/**
+	 * Disposables for handlers.
+	 */
+	#disposables: LSP.Disposable[] = [];
+
 	constructor({ context, logger }: LanguageServerModuleConstructorParameters) {
 		this.#context = context;
 		this.#logger = logger;
@@ -46,6 +51,13 @@ export class FormatterModule implements LanguageServerModule {
 		return options.validate.includes(document.languageId);
 	}
 
+	dispose(): void {
+		this.#context.connection.onDocumentFormatting(() => undefined);
+		this.#disposables.forEach((disposable) => disposable.dispose());
+		this.#disposables.length = 0;
+		this.#deregisterAll();
+	}
+
 	onInitialize({ capabilities }: LSP.InitializeParams): Partial<LSP.InitializeResult> {
 		this.#registerDynamically = Boolean(capabilities.textDocument?.formatting?.dynamicRegistration);
 
@@ -168,30 +180,42 @@ export class FormatterModule implements LanguageServerModule {
 		this.#logger?.debug('connection.onDocumentFormatting handler registered');
 
 		this.#logger?.debug('Registering documents.onDidOpen handler');
-		this.#context.documents.onDidOpen(({ document }) => this.#register(document));
+		this.#disposables.push(
+			this.#context.documents.onDidOpen(({ document }) => this.#register(document)),
+		);
 		this.#logger?.debug('documents.onDidOpen handler registered');
 
 		this.#logger?.debug('Registering documents.onDidChangeContent handler');
-		this.#context.documents.onDidChangeContent(({ document }) => this.#register(document));
+		this.#disposables.push(
+			this.#context.documents.onDidChangeContent(({ document }) => this.#register(document)),
+		);
 		this.#logger?.debug('documents.onDidChangeContent handler registered');
 
 		this.#logger?.debug('Registering documents.onDidSave handler');
-		this.#context.documents.onDidSave(({ document }) => this.#register(document));
+		this.#disposables.push(
+			this.#context.documents.onDidSave(({ document }) => this.#register(document)),
+		);
 		this.#logger?.debug('documents.onDidSave handler registered');
 
 		this.#logger?.debug('Registering documents.onDidClose handler');
-		this.#context.documents.onDidClose(({ document }) => this.#deregister(document.uri));
+		this.#disposables.push(
+			this.#context.documents.onDidClose(({ document }) => this.#deregister(document.uri)),
+		);
 		this.#logger?.debug('documents.onDidClose handler registered');
 
 		this.#logger?.debug('Registering DidChangeConfigurationNotification');
-		this.#context.notifications.on(LSP.DidChangeConfigurationNotification.type, () =>
-			this.#deregisterAll(),
+		this.#disposables.push(
+			this.#context.notifications.on(LSP.DidChangeConfigurationNotification.type, () =>
+				this.#deregisterAll(),
+			),
 		);
 		this.#logger?.debug('DidChangeConfigurationNotification registered');
 
 		this.#logger?.debug('Registering DidChangeWorkspaceFoldersNotification');
-		this.#context.notifications.on(LSP.DidChangeWorkspaceFoldersNotification.type, () =>
-			this.#deregisterAll(),
+		this.#disposables.push(
+			this.#context.notifications.on(LSP.DidChangeWorkspaceFoldersNotification.type, () =>
+				this.#deregisterAll(),
+			),
 		);
 		this.#logger?.debug('DidChangeWorkspaceFoldersNotification registered');
 	}
diff --git a/src/server/modules/old-stylelint-warning.ts b/src/server/modules/old-stylelint-warning.ts
index c63eca2..ba436ac 100644
--- a/src/server/modules/old-stylelint-warning.ts
+++ b/src/server/modules/old-stylelint-warning.ts
@@ -1,6 +1,7 @@
 import path from 'path';
 import fs from 'fs/promises';
 import semver from 'semver';
+import pathIsInside from 'path-is-inside';
 import type { TextDocument } from 'vscode-languageserver-textdocument';
 import type LSP from 'vscode-languageserver-protocol';
 import { getWorkspaceFolder } from '../../utils/documents';
@@ -35,16 +36,29 @@ export class OldStylelintWarningModule implements LanguageServerModule {
 	 */
 	#openMigrationGuide = false;
 
+	/**
+	 * Disposables for handlers.
+	 */
+	#disposables: LSP.Disposable[] = [];
+
 	constructor({ context, logger }: LanguageServerModuleConstructorParameters) {
 		this.#context = context;
 		this.#logger = logger;
 	}
 
+	dispose(): void {
+		this.#disposables.forEach((disposable) => disposable.dispose());
+		this.#disposables.length = 0;
+	}
+
 	onInitialize({ capabilities }: LSP.InitializeParams): void {
 		this.#openMigrationGuide = capabilities.window?.showDocument?.support ?? false;
 	}
 
-	async #getStylelintVersion(document: TextDocument): Promise<string | undefined> {
+	async #getStylelintVersion(
+		document: TextDocument,
+		workspaceFolder: string,
+	): Promise<string | undefined> {
 		const result = await this.#context.resolveStylelint(document);
 
 		if (!result) {
@@ -65,6 +79,14 @@ export class OldStylelintWarningModule implements LanguageServerModule {
 			return undefined;
 		}
 
+		if (!pathIsInside(packageDir, workspaceFolder)) {
+			this.#logger?.debug('Stylelint package root is not inside the workspace', {
+				uri: document.uri,
+			});
+
+			return undefined;
+		}
+
 		const manifestPath = path.join(packageDir, 'package.json');
 
 		try {
@@ -85,17 +107,6 @@ export class OldStylelintWarningModule implements LanguageServerModule {
 	}
 
 	async #check(document: TextDocument): Promise<string | undefined> {
-		const options = await this.#context.getOptions(document.uri);
-
-		if (!options.validate.includes(document.languageId)) {
-			this.#logger?.debug('Document should not be validated, ignoring', {
-				uri: document.uri,
-				language: document.languageId,
-			});
-
-			return undefined;
-		}
-
 		const workspaceFolder = await getWorkspaceFolder(this.#context.connection, document);
 
 		if (!workspaceFolder) {
@@ -116,7 +127,7 @@ export class OldStylelintWarningModule implements LanguageServerModule {
 
 		this.#checkedWorkspaces.add(workspaceFolder);
 
-		const stylelintVersion = await this.#getStylelintVersion(document);
+		const stylelintVersion = await this.#getStylelintVersion(document, workspaceFolder);
 
 		if (!stylelintVersion) {
 			return undefined;
@@ -144,39 +155,41 @@ export class OldStylelintWarningModule implements LanguageServerModule {
 	onDidRegisterHandlers(): void {
 		this.#logger?.debug('Registering onDidOpen handler');
 
-		this.#context.documents.onDidOpen(async ({ document }) => {
-			const stylelintVersion = await this.#check(document);
-
-			if (!stylelintVersion) {
-				return;
-			}
+		this.#disposables.push(
+			this.#context.documents.onDidOpen(async ({ document }) => {
+				const stylelintVersion = await this.#check(document);
 
-			this.#logger?.warn(`Found unsupported version of Stylelint: ${stylelintVersion}`);
+				if (!stylelintVersion) {
+					return;
+				}
 
-			const message = `Stylelint version ${stylelintVersion} is no longer supported. While it may continue to work for a while, you may encounter unexpected behavior. Please upgrade to version 14.0.0 or newer. See the migration guide for more information.`;
+				this.#logger?.warn(`Found unsupported version of Stylelint: ${stylelintVersion}`);
 
-			if (!this.#openMigrationGuide) {
-				this.#context.connection.window.showWarningMessage(message);
+				const message = `Stylelint version ${stylelintVersion} is no longer supported. While it may continue to work for a while, you may encounter unexpected behavior. Please upgrade to version 14.0.0 or newer. See the migration guide for more information.`;
 
-				return;
-			}
+				if (!this.#openMigrationGuide) {
+					this.#context.connection.window.showWarningMessage(message);
 
-			const warningResponse = await this.#context.connection.window.showWarningMessage(message, {
-				title: 'Open migration guide',
-			});
+					return;
+				}
 
-			if (warningResponse?.title === 'Open migration guide') {
-				// Open URL in browser
-				const showURIResponse = await this.#context.connection.window.showDocument({
-					uri: 'https://github.com/stylelint/vscode-stylelint#migrating-from-vscode-stylelint-0xstylelint-13x',
-					external: true,
+				const warningResponse = await this.#context.connection.window.showWarningMessage(message, {
+					title: 'Open migration guide',
 				});
 
-				if (!showURIResponse.success) {
-					this.#logger?.warn('Failed to open migration guide');
+				if (warningResponse?.title === 'Open migration guide') {
+					// Open URL in browser
+					const showURIResponse = await this.#context.connection.window.showDocument({
+						uri: 'https://github.com/stylelint/vscode-stylelint#migrating-from-vscode-stylelint-0xstylelint-13x',
+						external: true,
+					});
+
+					if (!showURIResponse.success) {
+						this.#logger?.warn('Failed to open migration guide');
+					}
 				}
-			}
-		});
+			}),
+		);
 
 		this.#logger?.debug('onDidOpen handler registered');
 	}
diff --git a/src/server/modules/validator.ts b/src/server/modules/validator.ts
index 8432fa2..ddd7e47 100644
--- a/src/server/modules/validator.ts
+++ b/src/server/modules/validator.ts
@@ -26,11 +26,21 @@ export class ValidatorModule implements LanguageServerModule {
 	 */
 	#documentDiagnostics = new Map<LSP.DocumentUri, LSP.Diagnostic[]>();
 
+	/**
+	 * Disposables for handlers.
+	 */
+	#disposables: LSP.Disposable[] = [];
+
 	constructor({ context, logger }: LanguageServerModuleConstructorParameters) {
 		this.#context = context;
 		this.#logger = logger;
 	}
 
+	dispose(): void {
+		this.#disposables.forEach((disposable) => disposable.dispose());
+		this.#disposables.length = 0;
+	}
+
 	async #shouldValidate(document: TextDocument): Promise<boolean> {
 		const options = await this.#context.getOptions(document.uri);
 
@@ -106,22 +116,28 @@ export class ValidatorModule implements LanguageServerModule {
 	onDidRegisterHandlers(): void {
 		this.#logger?.debug('Registering handlers');
 
-		this.#context.notifications.on(
-			DidChangeWatchedFilesNotification.type,
-			async () => await this.#validateAll(),
+		this.#disposables.push(
+			this.#context.notifications.on(
+				DidChangeWatchedFilesNotification.type,
+				async () => await this.#validateAll(),
+			),
 		);
 
 		this.#logger?.debug('onDidChangeWatchedFiles handler registered');
 
-		this.#context.documents.onDidChangeContent(
-			async ({ document }) => await this.#validate(document),
+		this.#disposables.push(
+			this.#context.documents.onDidChangeContent(
+				async ({ document }) => await this.#validate(document),
+			),
 		);
 
 		this.#logger?.debug('onDidChangeContent handler registered');
 
-		this.#context.documents.onDidClose(({ document }) => {
-			this.#clearDiagnostics(document);
-		});
+		this.#disposables.push(
+			this.#context.documents.onDidClose(({ document }) => {
+				this.#clearDiagnostics(document);
+			}),
+		);
 
 		this.#logger?.debug('onDidClose handler registered');
 		this.#logger?.debug('Handlers registered');
diff --git a/src/server/server.ts b/src/server/server.ts
index e0e087d..e39bcdb 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -4,6 +4,7 @@ import type LSP from 'vscode-languageserver-protocol';
 import { TextDocuments } from 'vscode-languageserver/node';
 import {
 	DidChangeConfigurationNotification,
+	Disposable,
 	InitializedNotification,
 	TextDocumentSyncKind,
 } from 'vscode-languageserver-protocol';
@@ -25,6 +26,7 @@ import {
 	LanguageServerHandlerReturnValues,
 	Notification,
 } from './types';
+import { createLogger } from './create-logger';
 
 const defaultOptions: LanguageServerOptions = {
 	codeAction: {
@@ -46,19 +48,31 @@ const defaultOptions: LanguageServerOptions = {
 	validate: ['css', 'postcss'],
 };
 
+const enum State {
+	New = 'New',
+	Started = 'Started',
+	Initialized = 'Initialized',
+	Disposed = 'Disposed',
+}
+
 /**
  * Stylelint language server.
  */
-export class StylelintLanguageServer {
+export class StylelintLanguageServer implements Disposable {
+	/**
+	 * The language server state.
+	 */
+	#state = State.New;
+
 	/**
 	 * The language server connection.
 	 */
 	#connection: Connection;
 
 	/**
-	 * The logger to use, if any.
+	 * The logger to use.
 	 */
-	#logger: winston.Logger | undefined;
+	#logger: winston.Logger;
 
 	/**
 	 * The notification manager for the connection.
@@ -111,12 +125,17 @@ export class StylelintLanguageServer {
 	 */
 	#scopedOptions = new Map<string, LanguageServerOptions>();
 
+	/**
+	 * Disposables for handlers.
+	 */
+	#disposables: LSP.Disposable[] = [];
+
 	/**
 	 * Creates a new Stylelint language server.
 	 */
 	constructor({ connection, logger, modules }: LanguageServerConstructorParameters) {
 		this.#connection = connection;
-		this.#logger = logger?.child({ component: 'language-server' });
+		this.#logger = (logger ?? createLogger(connection))?.child({ component: 'language-server' });
 		this.#notifications = new NotificationManager(connection, this.#logger);
 		this.#commands = new CommandManager(connection, this.#logger);
 		this.#globalOptions = defaultOptions;
@@ -149,7 +168,7 @@ export class StylelintLanguageServer {
 
 		if (modules) {
 			for (const Module of modules) {
-				this.#logger?.info('Registering module', { module: Module.id });
+				this.#logger.info('Registering module', { module: Module.id });
 
 				if (!Module.id) {
 					throw new Error('Modules must have an ID');
@@ -170,23 +189,84 @@ export class StylelintLanguageServer {
 
 				this.#modules.set(Module.id, module);
 
-				this.#logger?.info('Module registered', { module: Module.id });
+				this.#logger.info('Module registered', { module: Module.id });
 			}
 		}
 	}
 
+	/**
+	 * Transitions the server's state.
+	 * @param state The new state.
+	 */
+	#transition(state: State): void {
+		if (this.#state === state) {
+			throw new Error(`Cannot transition from state ${state} to itself`);
+		}
+
+		// We only have a handful of states, so we can use a switch statement.
+		switch (this.#state) {
+			// We don't need to check State.New since the only invalid state
+			// transition from New is to Initialized, and the handler for
+			// InitializedNotification isn't registered until the server is
+			// started.
+
+			// We don't need to check State.Started since the only invalid state
+			// transition from Started is to New and we never transition to New.
+
+			case State.Initialized:
+				if (state !== State.Disposed) {
+					throw new Error('Can only transition state from Initialized to Disposed');
+				}
+
+				break;
+
+			case State.Disposed:
+				throw new Error('Cannot transition from Disposed');
+
+			default:
+				break;
+		}
+
+		this.#state = state;
+	}
+
 	/**
 	 * Starts the language server.
 	 */
 	start(): void {
-		this.#logger?.info('Starting language server');
+		this.#transition(State.Started);
+
+		this.#logger.info('Starting language server');
 
 		this.#documents.listen(this.#connection);
 		this.#connection.listen();
 
 		this.#registerHandlers();
 
-		this.#logger?.info('Language server started');
+		this.#logger.info('Language server started');
+	}
+
+	/**
+	 * Disposes the language server.
+	 */
+	dispose(): void {
+		try {
+			this.#transition(State.Disposed);
+		} catch {
+			return;
+		}
+
+		this.#logger.info('Stopping language server');
+
+		this.#modules.forEach((module) => module.dispose());
+		this.#disposables.forEach((disposable) => disposable.dispose());
+		this.#disposables.length = 0;
+		this.#connection.onInitialize(() => ({ capabilities: {} }));
+		this.#connection.onShutdown(() => undefined);
+		this.#modules.clear();
+		this.#notifications.dispose();
+		this.#commands.dispose();
+		this.#connection.dispose();
 	}
 
 	#displayError(error: unknown): void {
@@ -201,19 +281,19 @@ export class StylelintLanguageServer {
 		const cached = this.#scopedOptions.get(resource);
 
 		if (cached) {
-			this.#logger?.debug('Returning cached options', { resource });
+			this.#logger.debug('Returning cached options', { resource });
 
 			return cached;
 		}
 
-		this.#logger?.debug('Requesting options from client', { resource });
+		this.#logger.debug('Requesting options from client', { resource });
 
 		const options = (await this.#connection.workspace.getConfiguration({
 			scopeUri: resource,
 			section: 'stylelint',
 		})) as unknown;
 
-		this.#logger?.debug('Received options from client', { resource, options });
+		this.#logger.debug('Received options from client', { resource, options });
 
 		const withDefaults = mergeOptionsWithDefaults(options, defaultOptions);
 
@@ -221,7 +301,7 @@ export class StylelintLanguageServer {
 
 		this.#scopedOptions.set(resource, withDefaults);
 
-		this.#logger?.debug('Returning options', { resource, options: withDefaults });
+		this.#logger.debug('Returning options', { resource, options: withDefaults });
 
 		return withDefaults;
 	}
@@ -230,7 +310,7 @@ export class StylelintLanguageServer {
 	 * Resolves the Stylelint package for the given document.
 	 */
 	async #resolveStylelint(document: TextDocument): Promise<StylelintResolutionResult | undefined> {
-		this.#logger?.debug('Resolving Stylelint', { uri: document.uri });
+		this.#logger.debug('Resolving Stylelint', { uri: document.uri });
 
 		try {
 			const options = await this.#getOptions(document.uri);
@@ -238,18 +318,18 @@ export class StylelintLanguageServer {
 			const result = await this.#resolver.resolve(options, document);
 
 			if (result) {
-				this.#logger?.debug('Stylelint resolved', {
+				this.#logger.debug('Stylelint resolved', {
 					uri: document.uri,
 					resolvedPath: result.resolvedPath,
 				});
 			} else {
-				this.#logger?.warn('Failed to resolve Stylelint', { uri: document.uri });
+				this.#logger.warn('Failed to resolve Stylelint', { uri: document.uri });
 			}
 
 			return result;
 		} catch (error) {
 			this.#displayError(error);
-			this.#logger?.error('Error resolving Stylelint', { uri: document.uri, error });
+			this.#logger.error('Error resolving Stylelint', { uri: document.uri, error });
 
 			return undefined;
 		}
@@ -262,19 +342,19 @@ export class StylelintLanguageServer {
 		document: TextDocument,
 		linterOptions: Partial<stylelint.LinterOptions> = {},
 	): Promise<LintDiagnostics | undefined> {
-		this.#logger?.debug('Linting document', { uri: document.uri, linterOptions });
+		this.#logger.debug('Linting document', { uri: document.uri, linterOptions });
 
 		try {
 			const options = await this.#getOptions(document.uri);
 
 			const results = await this.#runner.lintDocument(document, linterOptions, options);
 
-			this.#logger?.debug('Lint run complete', { uri: document.uri, results });
+			this.#logger.debug('Lint run complete', { uri: document.uri, results });
 
 			return results;
 		} catch (err) {
 			this.#displayError(err);
-			this.#logger?.error('Error running lint', { uri: document.uri, error: err });
+			this.#logger.error('Error running lint', { uri: document.uri, error: err });
 
 			return undefined;
 		}
@@ -292,12 +372,12 @@ export class StylelintLanguageServer {
 
 			const edits = await getFixes(this.#runner, document, linterOptions, options);
 
-			this.#logger?.debug('Fixes retrieved', { uri: document.uri, edits });
+			this.#logger.debug('Fixes retrieved', { uri: document.uri, edits });
 
 			return edits;
 		} catch (error) {
 			this.#displayError(error);
-			this.#logger?.error('Error getting fixes', { uri: document.uri, error });
+			this.#logger.error('Error getting fixes', { uri: document.uri, error });
 
 			return [];
 		}
@@ -316,28 +396,35 @@ export class StylelintLanguageServer {
 	 * to register their handlers.
 	 */
 	#registerHandlers(): void {
-		this.#logger?.info('Registering handlers');
+		this.#logger.info('Registering handlers');
 
 		this.#connection.onInitialize(this.#onInitialize.bind(this));
-		this.#logger?.debug('connection.onInitialize handler registered');
+		this.#logger.debug('connection.onInitialize handler registered');
+
+		this.#disposables.push(
+			this.#notifications.on(InitializedNotification.type, this.#onInitialized.bind(this)),
+		);
+		this.#logger.debug('connection.onInitialized handler registered');
 
-		this.#notifications.on(InitializedNotification.type, this.#onInitialized.bind(this));
-		this.#logger?.debug('connection.onInitialized handler registered');
+		this.#connection.onShutdown(() => this.dispose());
+		this.#logger.debug('connection.onShutdown handler registered');
 
 		this.#commands.register();
 
-		this.#notifications.on(
-			DidChangeConfigurationNotification.type,
-			this.#onDidChangeConfiguration.bind(this),
+		this.#disposables.push(
+			this.#notifications.on(
+				DidChangeConfigurationNotification.type,
+				this.#onDidChangeConfiguration.bind(this),
+			),
 		);
-		this.#logger?.debug('connection.onDidChangeConfiguration handler registered');
+		this.#logger.debug('connection.onDidChangeConfiguration handler registered');
 
-		this.#documents.onDidClose(this.#onDidCloseDocument.bind(this));
-		this.#logger?.debug('documents.onDidClose handler registered');
+		this.#disposables.push(this.#documents.onDidClose(this.#onDidCloseDocument.bind(this)));
+		this.#logger.debug('documents.onDidClose handler registered');
 
 		this.#invokeHandlers('onDidRegisterHandlers');
 
-		this.#logger?.info('Handlers registered');
+		this.#logger.info('Handlers registered');
 	}
 
 	/**
@@ -348,7 +435,7 @@ export class StylelintLanguageServer {
 		P extends LanguageServerHandlerParameters[K],
 		R extends LanguageServerHandlerReturnValues[K],
 	>(handlerName: K, ...params: P): { [moduleName: string]: R[] } {
-		this.#logger?.debug(`Invoking ${String(handlerName)}`);
+		this.#logger.debug(`Invoking ${String(handlerName)}`);
 
 		const returnValues = Object.create(null) as { [moduleName: string]: R[] };
 
@@ -359,13 +446,13 @@ export class StylelintLanguageServer {
 				try {
 					returnValues[id] = handler.apply(module, params);
 
-					this.#logger?.debug(`Invoked ${String(handlerName)}`, {
+					this.#logger.debug(`Invoked ${String(handlerName)}`, {
 						module: id,
 						returnValue: returnValues[id],
 					});
 				} catch (error) {
 					this.#displayError(error);
-					this.#logger?.error(`Error invoking ${String(handlerName)}`, {
+					this.#logger.error(`Error invoking ${String(handlerName)}`, {
 						module: id,
 						error,
 					});
@@ -377,7 +464,7 @@ export class StylelintLanguageServer {
 	}
 
 	#onInitialize(params: LSP.InitializeParams): LSP.InitializeResult {
-		this.#logger?.debug('received onInitialize', { params });
+		this.#logger.debug('received onInitialize', { params });
 
 		const result: LSP.InitializeResult = {
 			capabilities: {
@@ -389,7 +476,7 @@ export class StylelintLanguageServer {
 		};
 
 		if (params.capabilities.workspace?.configuration) {
-			this.#logger?.debug(
+			this.#logger.debug(
 				'Client reports workspace configuration support; using scoped configuration',
 			);
 
@@ -402,16 +489,18 @@ export class StylelintLanguageServer {
 			}
 		}
 
-		this.#logger?.debug('Returning initialization results', { result });
+		this.#logger.debug('Returning initialization results', { result });
 
 		return result;
 	}
 
 	async #onInitialized(params: LSP.InitializedParams): Promise<void> {
-		this.#logger?.debug('received onInitialized', { params });
+		this.#transition(State.Initialized);
+
+		this.#logger.debug('received onInitialized', { params });
 
 		if (this.#hasConfigurationCapability) {
-			this.#logger?.debug('Registering DidChangeConfigurationNotification');
+			this.#logger.debug('Registering DidChangeConfigurationNotification');
 
 			await this.#connection.client.register(DidChangeConfigurationNotification.type, {
 				section: 'stylelint',
@@ -420,7 +509,7 @@ export class StylelintLanguageServer {
 	}
 
 	#onDidCloseDocument({ document }: TextDocumentChangeEvent<TextDocument>): void {
-		this.#logger?.debug('received documents.onDidClose, clearing cached options', {
+		this.#logger.debug('received documents.onDidClose, clearing cached options', {
 			uri: document.uri,
 		});
 
@@ -429,7 +518,7 @@ export class StylelintLanguageServer {
 
 	#onDidChangeConfiguration(params: LSP.DidChangeConfigurationParams): void {
 		if (this.#hasConfigurationCapability) {
-			this.#logger?.debug('received onDidChangeConfiguration, clearing cached options', { params });
+			this.#logger.debug('received onDidChangeConfiguration, clearing cached options', { params });
 
 			this.#scopedOptions.clear();
 
@@ -440,7 +529,7 @@ export class StylelintLanguageServer {
 			return;
 		}
 
-		this.#logger?.debug('received onDidChangeConfiguration', { params });
+		this.#logger.debug('received onDidChangeConfiguration', { params });
 
 		this.#globalOptions = mergeOptionsWithDefaults(
 			(params.settings as { stylelint: unknown }).stylelint,
@@ -449,7 +538,7 @@ export class StylelintLanguageServer {
 
 		Object.freeze(this.#globalOptions);
 
-		this.#logger?.debug('Global options updated', { options: this.#globalOptions });
+		this.#logger.debug('Global options updated', { options: this.#globalOptions });
 
 		this.#invokeHandlers('onDidChangeConfiguration');
 	}
diff --git a/src/server/types.ts b/src/server/types.ts
index 4f9d107..6a40cb6 100644
--- a/src/server/types.ts
+++ b/src/server/types.ts
@@ -1,4 +1,4 @@
-import type { Connection } from 'vscode-languageserver';
+import type { Connection, Disposable } from 'vscode-languageserver';
 import type { TextDocuments } from 'vscode-languageserver/node';
 import type { TextDocument, TextEdit } from 'vscode-languageserver-textdocument';
 import { CodeActionKind as VSCodeActionKind } from 'vscode-languageserver-types';
@@ -31,7 +31,7 @@ export const CodeActionKind = {
  */
 export enum Notification {
 	DidRegisterCodeActionRequestHandler = 'stylelint/didRegisterCodeActionRequestHandler',
-	DidRegisterDocumentFormattingEditProvider = 'textDocument/didRegisterDocumentFormattingEditProvider',
+	DidRegisterDocumentFormattingEditProvider = 'stylelint/didRegisterDocumentFormattingEditProvider',
 	DidResetConfiguration = 'stylelint/didResetConfiguration',
 }
 
@@ -127,7 +127,7 @@ export interface LanguageServerContext {
 /**
  * A language server module.
  */
-export interface LanguageServerModule {
+export interface LanguageServerModule extends Disposable {
 	/**
 	 * Handler called when the server is initializing.
 	 */
@@ -202,7 +202,7 @@ export type LanguageServerConstructorParameters = {
 	connection: Connection;
 
 	/**
-	 * The logger to use.
+	 * The logger to use. If not provided, a default logger will be used.
 	 */
 	logger?: winston.Logger;
 
diff --git a/src/utils/logging/language-server-transport.ts b/src/utils/logging/language-server-transport.ts
index e701d87..357b7ae 100644
--- a/src/utils/logging/language-server-transport.ts
+++ b/src/utils/logging/language-server-transport.ts
@@ -37,12 +37,18 @@ export class LanguageServerTransport extends TransportStream {
 			this.emit('logged', info);
 		});
 
-		const logFunc = getLogFunction(this.#console, String(info[LEVEL]));
-
-		if (typeof logFunc === 'function') {
-			logFunc.call(this.#console, String(info[MESSAGE]));
-		} else {
-			this.#console.log(String(info[MESSAGE]));
+		try {
+			const logFunc = getLogFunction(this.#console, String(info[LEVEL]));
+
+			if (typeof logFunc === 'function') {
+				logFunc.call(this.#console, String(info[MESSAGE]));
+			} else {
+				this.#console.log(String(info[MESSAGE]));
+			}
+		} catch (error) {
+			if (!(error instanceof Error) || !error.message.includes('Connection is disposed')) {
+				this.emit('error', error);
+			}
 		}
 
 		callback();
diff --git a/src/utils/lsp/command-manager.ts b/src/utils/lsp/command-manager.ts
index 51445df..e488907 100644
--- a/src/utils/lsp/command-manager.ts
+++ b/src/utils/lsp/command-manager.ts
@@ -1,11 +1,11 @@
 import * as LSP from 'vscode-languageserver-protocol';
-import type { Connection, ServerRequestHandler } from 'vscode-languageserver';
+import type { Connection, Disposable, ServerRequestHandler } from 'vscode-languageserver';
 import type winston from 'winston';
 
 /**
  * Allows registering and executing commands and their handlers by name.
  */
-export class CommandManager {
+export class CommandManager implements Disposable {
 	/**
 	 * The language server connection.
 	 */
@@ -32,6 +32,13 @@ export class CommandManager {
 		this.#logger = logger;
 	}
 
+	dispose(): void {
+		this.#logger?.debug('Disposing command manager');
+
+		this.#commands.clear();
+		this.#connection.onExecuteCommand(() => undefined);
+	}
+
 	/**
 	 * Registers a handler for a command.
 	 */
@@ -43,22 +50,36 @@ export class CommandManager {
 			never,
 			void
 		>,
-	): void {
+	): Disposable {
 		if (Array.isArray(name)) {
-			this.#logger?.debug('Registering commands', {
-				commands: name,
-			});
+			this.#logger?.debug('Registering commands', { commands: name });
 
 			for (const commandName of name) {
 				this.#commands.set(commandName, handler);
 			}
-		} else {
-			this.#logger?.debug('Registering command', {
-				command: name,
-			});
 
-			this.#commands.set(name, handler);
+			return {
+				dispose: () => {
+					this.#logger?.debug('Deregistering commands', { commands: name });
+
+					for (const commandName of name) {
+						this.#commands.delete(commandName);
+					}
+				},
+			};
 		}
+
+		this.#logger?.debug('Registering command', { command: name });
+
+		this.#commands.set(name, handler);
+
+		return {
+			dispose: () => {
+				this.#logger?.debug('Deregistering command', { command: name });
+
+				this.#commands.delete(name);
+			},
+		};
 	}
 
 	/**
diff --git a/src/utils/lsp/notification-manager.ts b/src/utils/lsp/notification-manager.ts
index 67203d7..b07d389 100644
--- a/src/utils/lsp/notification-manager.ts
+++ b/src/utils/lsp/notification-manager.ts
@@ -1,5 +1,5 @@
 import * as LSP from 'vscode-languageserver-protocol';
-import { Connection } from 'vscode-languageserver';
+import { Connection, Disposable } from 'vscode-languageserver';
 import type winston from 'winston';
 import { MaybeAsync } from '../types';
 
@@ -10,13 +10,13 @@ type Handlers = Map<
 	| LSP.NotificationType<unknown>
 	| string
 	| undefined,
-	MaybeAsync<LSP.GenericNotificationHandler>[]
+	Set<MaybeAsync<LSP.GenericNotificationHandler>>
 >;
 
 /**
  * Allows registering multiple handlers for the same notification type.
  */
-export class NotificationManager {
+export class NotificationManager implements Disposable {
 	/**
 	 * The connection to the server.
 	 */
@@ -40,6 +40,23 @@ export class NotificationManager {
 		this.#logger = logger;
 	}
 
+	dispose(): void {
+		this.#logger?.debug('Disposing notification manager');
+
+		for (const [type] of this.#notifications) {
+			if (type) {
+				this.#connection.onNotification(
+					type as LSP.ProtocolNotificationType<unknown, unknown>,
+					() => undefined,
+				);
+			} else {
+				this.#connection.onNotification(() => undefined);
+			}
+		}
+
+		this.#notifications.clear();
+	}
+
 	async #handleNotification<P, R0>(
 		key:
 			| LSP.ProtocolNotificationType0<R0>
@@ -61,8 +78,10 @@ export class NotificationManager {
 		// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 		const handlers = this.#notifications.get(key)!;
 
-		await Promise.all(
-			handlers.map(async (handler) => {
+		const funcs: (() => Promise<void>)[] = [];
+
+		for (const handler of handlers) {
+			funcs.push(async () => {
 				try {
 					await handler(...params);
 				} catch (error) {
@@ -71,8 +90,10 @@ export class NotificationManager {
 						error,
 					});
 				}
-			}),
-		);
+			});
+		}
+
+		await Promise.all(funcs.map((func) => func()));
 	}
 
 	/**
@@ -81,15 +102,15 @@ export class NotificationManager {
 	on<R0>(
 		type: LSP.ProtocolNotificationType0<R0>,
 		handler: MaybeAsync<LSP.NotificationHandler0>,
-	): void;
+	): Disposable;
 	on<P, R0>(
 		type: LSP.ProtocolNotificationType<P, R0>,
 		handler: MaybeAsync<LSP.NotificationHandler<P>>,
-	): void;
-	on(type: LSP.NotificationType0, handler: MaybeAsync<LSP.NotificationHandler0>): void;
-	on<P>(type: LSP.NotificationType<P>, handler: MaybeAsync<LSP.NotificationHandler<P>>): void;
-	on(type: string, handler: MaybeAsync<LSP.GenericNotificationHandler>): void;
-	on(handler: LSP.StarNotificationHandler): void;
+	): Disposable;
+	on(type: LSP.NotificationType0, handler: MaybeAsync<LSP.NotificationHandler0>): Disposable;
+	on<P>(type: LSP.NotificationType<P>, handler: MaybeAsync<LSP.NotificationHandler<P>>): Disposable;
+	on(type: string, handler: MaybeAsync<LSP.GenericNotificationHandler>): Disposable;
+	on(handler: LSP.StarNotificationHandler): Disposable;
 	on<P, R0>(
 		type:
 			| LSP.ProtocolNotificationType0<R0>
@@ -97,9 +118,9 @@ export class NotificationManager {
 			| LSP.NotificationType0
 			| LSP.NotificationType<P>
 			| string
-			| LSP.StarNotificationHandler,
+			| MaybeAsync<LSP.StarNotificationHandler>,
 		handler?: MaybeAsync<LSP.GenericNotificationHandler>,
-	): void {
+	): Disposable {
 		const isStar = typeof type === 'function';
 		const [key, func] = isStar ? [undefined, type] : [type, handler];
 
@@ -107,21 +128,27 @@ export class NotificationManager {
 			throw new Error('Handler must be defined');
 		}
 
+		const disposable = {
+			dispose: () => {
+				this.#notifications.get(key)?.delete(func);
+			},
+		};
+
 		const existing = this.#notifications.get(key);
 
 		if (existing) {
-			existing.push(func);
+			existing.add(func);
 
-			return;
+			return disposable;
 		}
 
-		this.#notifications.set(key, [func]);
+		this.#notifications.set(key, new Set([func]));
 
 		if (isStar) {
 			// eslint-disable-next-line @typescript-eslint/no-misused-promises
 			this.#connection.onNotification((...params) => this.#handleNotification(undefined, params));
 
-			return;
+			return disposable;
 		}
 
 		this.#connection.onNotification<P, R0>(
@@ -129,5 +156,7 @@ export class NotificationManager {
 			// eslint-disable-next-line @typescript-eslint/no-misused-promises
 			(...params) => this.#handleNotification(type, params),
 		);
+
+		return disposable;
 	}
 }
diff --git a/src/utils/stylelint/warning-to-diagnostic.ts b/src/utils/stylelint/warning-to-diagnostic.ts
index 22b3910..7d773e5 100644
--- a/src/utils/stylelint/warning-to-diagnostic.ts
+++ b/src/utils/stylelint/warning-to-diagnostic.ts
@@ -46,7 +46,10 @@ export function warningToDiagnostic(
 	ruleMetadata?: stylelint.LinterResult['ruleMetadata'],
 ): Diagnostic {
 	const start = Position.create(warning.line - 1, warning.column - 1);
-	const end = Position.create(warning.line - 1, warning.column);
+	const end =
+		typeof warning.endLine === 'number' && typeof warning.endColumn === 'number'
+			? Position.create(warning.endLine - 1, warning.endColumn - 1)
+			: Position.create(warning.line - 1, warning.column);
 
 	const ruleDocUrl = ruleMetadata?.[warning.rule]?.url;
 
diff --git a/src/utils/types.ts b/src/utils/types.ts
index 60c1c13..f0b73ff 100644
--- a/src/utils/types.ts
+++ b/src/utils/types.ts
@@ -28,3 +28,17 @@ export type ExtractKeysOfValueType<T, V> = {
 export type MaybeAsync<T extends (...args: any[]) => any> = (
 	...args: Parameters<T>
 ) => Promise<ReturnType<T>> | ReturnType<T>;
+
+/**
+ * Given a type `T`, returns a type with only the public properties of `T`.
+ */
+export type PublicOnly<T> = Pick<T, keyof T>;
+
+/**
+ * Given a type `T`, returns a type with only the public properties of `T` and
+ * its descendants.
+ */
+export type PublicOnlyDeep<T> = {
+	// eslint-disable-next-line @typescript-eslint/ban-types
+	[K in keyof T]: T[K] extends Function ? T[K] : T[K] extends object ? PublicOnlyDeep<T[K]> : T[K];
+};


Test patch:
diff --git a/src/extension/__tests__/__snapshots__/extension.ts.snap b/src/extension/__tests__/__snapshots__/extension.ts.snap
index 55ff3e2..69d1b0e 100644
--- a/src/extension/__tests__/__snapshots__/extension.ts.snap
+++ b/src/extension/__tests__/__snapshots__/extension.ts.snap
@@ -28,7 +28,10 @@ Array [
       },
     ],
     "synchronize": Object {
-      "fileEvents": undefined,
+      "fileEvents": Array [
+        undefined,
+        undefined,
+      ],
     },
   },
 ]
diff --git a/src/extension/__tests__/extension.ts b/src/extension/__tests__/extension.ts
index 1f75822..c4323ab 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({
@@ -113,10 +117,15 @@ describe('Extension entry point', () => {
 	it('should watch for changes to Stylelint configuration files', () => {
 		activate(mockExtensionContext);
 
-		expect(mockWorkspace.createFileSystemWatcher).toHaveBeenCalled();
+		expect(mockWorkspace.createFileSystemWatcher).toHaveBeenCalledTimes(2);
 		expect(mockWorkspace.createFileSystemWatcher.mock.calls[0]).toMatchInlineSnapshot(`
 		Array [
-		  "**/{.stylelintrc{,.js,.json,.yaml,.yml},stylelint.config.js,.stylelintignore}",
+		  "**/.stylelintrc{,.js,.json,.yaml,.yml}",
+		]
+	`);
+		expect(mockWorkspace.createFileSystemWatcher.mock.calls[1]).toMatchInlineSnapshot(`
+		Array [
+		  "**/{stylelint.config.js,.stylelintignore}",
 		]
 	`);
 	});
@@ -142,42 +151,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 +192,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 +238,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 +252,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 +288,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 +313,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/src/server/__tests__/__snapshots__/server.ts.snap b/src/server/__tests__/__snapshots__/server.ts.snap
index 7643a2a..bc178c2 100644
--- a/src/server/__tests__/__snapshots__/server.ts.snap
+++ b/src/server/__tests__/__snapshots__/server.ts.snap
@@ -5,6 +5,7 @@ Object {
   "context": Object {
     "commands": Object {
       "__typed": [Function],
+      "dispose": [MockFunction],
       "on": [MockFunction],
       "register": [MockFunction],
     },
@@ -122,8 +123,17 @@ Object {
     },
     "displayError": [Function],
     "documents": Object {
+      "__typed": [Function],
+      "all": [MockFunction],
+      "get": [MockFunction],
+      "keys": [MockFunction],
       "listen": [MockFunction],
+      "onDidChangeContent": [MockFunction],
       "onDidClose": [MockFunction],
+      "onDidOpen": [MockFunction],
+      "onDidSave": [MockFunction],
+      "onWillSave": [MockFunction],
+      "onWillSaveWaitUntil": [MockFunction],
     },
     "getFixes": [Function],
     "getModule": [Function],
@@ -131,6 +141,7 @@ Object {
     "lintDocument": [Function],
     "notifications": Object {
       "__typed": [Function],
+      "dispose": [MockFunction],
       "on": [MockFunction],
     },
     "resolveStylelint": [Function],
diff --git a/src/server/__tests__/create-logger.ts b/src/server/__tests__/create-logger.ts
new file mode 100644
index 0000000..77498a3
--- /dev/null
+++ b/src/server/__tests__/create-logger.ts
@@ -0,0 +1,76 @@
+jest.mock('winston', () => {
+	const winston = jest.requireActual('winston');
+
+	return {
+		...winston,
+		transports: {
+			...winston.transports,
+			File: jest.fn(),
+		},
+	};
+});
+
+import type transports from 'winston/lib/winston/transports';
+import winston from 'winston';
+import { createLogger } from '../create-logger';
+
+type MockWinston = typeof winston & {
+	transports: typeof winston.transports & {
+		File: jest.Mock<typeof winston.transports.File>;
+	};
+};
+
+const mockConnection = serverMocks.getConnection();
+const mockWinston = winston as MockWinston;
+
+describe('createLogger', () => {
+	beforeEach(() => {
+		jest.clearAllMocks();
+	});
+
+	it('should return a logger', () => {
+		const logger = createLogger(mockConnection);
+
+		expect(logger).toHaveProperty('log');
+		expect(logger.log).toBeInstanceOf(Function);
+	});
+
+	it('should return a logger that logs to the connection', () => {
+		const logger = createLogger(mockConnection);
+
+		logger.debug('test');
+		logger.info('test');
+
+		expect(mockConnection.console.log).not.toHaveBeenCalled();
+		expect(mockConnection.console.info).toHaveBeenCalledTimes(1);
+		expect(mockConnection.console.info).toHaveBeenCalledWith('test');
+	});
+
+	it('should return a logger that logs to the connection with the correct level', () => {
+		const logger = createLogger(mockConnection, 'warn');
+
+		logger.warn('test');
+		logger.info('test');
+
+		expect(logger.isInfoEnabled()).toBe(false);
+		expect(mockConnection.console.info).not.toHaveBeenCalled();
+		expect(mockConnection.console.warn).toHaveBeenCalledTimes(1);
+		expect(mockConnection.console.warn).toHaveBeenCalledWith('test');
+	});
+
+	it('should add a file transport to the logger if a path is provided', () => {
+		const mockTransport =
+			new winston.transports.Console() as unknown as transports.FileTransportInstance;
+
+		mockWinston.transports.File.mockImplementation(() => mockTransport);
+
+		const logger = createLogger(mockConnection, 'info', 'test.log');
+
+		expect(mockWinston.transports.File).toHaveBeenCalledTimes(1);
+		expect(mockWinston.transports.File).toHaveBeenCalledWith({
+			filename: 'test.log',
+			format: expect.any(Object),
+		});
+		expect(logger.transports).toContain(mockTransport);
+	});
+});
diff --git a/src/server/__tests__/server.ts b/src/server/__tests__/server.ts
index 3ea2156..9b29e05 100644
--- a/src/server/__tests__/server.ts
+++ b/src/server/__tests__/server.ts
@@ -14,7 +14,7 @@ import type { TextDocument, TextEdit } from 'vscode-languageserver-textdocument'
 import { TextDocuments } from 'vscode-languageserver/node';
 import * as LSP from 'vscode-languageserver-protocol';
 import { displayError, NotificationManager, CommandManager } from '../../utils/lsp';
-import { StylelintRunner } from '../../utils/stylelint';
+import { LintDiagnostics, StylelintRunner } from '../../utils/stylelint';
 import { getFixes } from '../../utils/documents';
 import { StylelintResolver } from '../../utils/packages';
 import { StylelintLanguageServer } from '../server';
@@ -38,18 +38,38 @@ const mockConnection = serverMocks.getConnection();
 const mockLogger = serverMocks.getLogger();
 const mockNotifications = serverMocks.getNotificationManager();
 const mockCommands = serverMocks.getCommandManager();
+const mockDocuments = serverMocks.getTextDocuments();
 
 mockNotificationManager.mockImplementation(() => mockNotifications.__typed());
 mockCommandManager.mockImplementation(() => mockCommands.__typed());
+mockTextDocuments.mockImplementation(() => mockDocuments.__typed());
+
+abstract class BaseDisposable implements LSP.Disposable {
+	public dispose(): void {
+		// noop
+	}
+}
+
+const getContextModule = (
+	id = 'test-module',
+): [LanguageServerModuleConstructor, () => LanguageServerContext | undefined] => {
+	let context: LanguageServerContext | undefined;
+
+	class TestModule extends BaseDisposable implements LanguageServerModule {
+		static id = id;
+
+		constructor(params: LanguageServerModuleConstructorParameters) {
+			super();
+			context = params.context;
+		}
+	}
+
+	return [TestModule, () => context];
+};
 
 describe('StylelintLanguageServer', () => {
 	beforeEach(() => {
 		jest.clearAllMocks();
-
-		mockTextDocuments.mockReturnValue({
-			listen: jest.fn(),
-			onDidClose: jest.fn(),
-		} as unknown as TextDocuments<TextDocument>);
 	});
 
 	test('should be constructable', () => {
@@ -72,11 +92,12 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('should accept server modules', () => {
-		class TestModule {
+		class TestModule extends BaseDisposable {
 			static params: LanguageServerModuleConstructorParameters | undefined;
 			static id = 'test-module';
 
 			constructor(params: LanguageServerModuleConstructorParameters) {
+				super();
 				TestModule.params = params;
 			}
 		}
@@ -94,11 +115,11 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('should not accept modules with duplicate IDs', () => {
-		class TestModule {
+		class TestModule extends BaseDisposable {
 			static id = 'test-module';
 		}
 
-		class TestModule2 {
+		class TestModule2 extends BaseDisposable {
 			static id = 'test-module';
 		}
 
@@ -112,10 +133,11 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('should prevent modules from modifying context properties', () => {
-		class TestModule {
+		class TestModule extends BaseDisposable {
 			static id = 'test-module';
 
 			constructor({ context }: LanguageServerModuleConstructorParameters) {
+				super();
 				context.connection = {} as unknown as Connection;
 			}
 		}
@@ -130,7 +152,7 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('should not accept modules without an ID', () => {
-		class TestModule {
+		class TestModule extends BaseDisposable {
 			static id = undefined;
 		}
 
@@ -144,7 +166,7 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('should not accept modules with a non-string ID', () => {
-		class TestModule {
+		class TestModule extends BaseDisposable {
 			static id = 1;
 		}
 
@@ -169,7 +191,7 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('should combine initialization results from modules', () => {
-		class TestModuleA {
+		class TestModuleA extends BaseDisposable {
 			static id = 'module-a';
 
 			onInitialize(): Partial<LSP.InitializeResult> {
@@ -179,7 +201,7 @@ describe('StylelintLanguageServer', () => {
 			}
 		}
 
-		class TestModuleB {
+		class TestModuleB extends BaseDisposable {
 			static id = 'module-b';
 
 			onInitialize(): Partial<LSP.InitializeResult> {
@@ -215,7 +237,7 @@ describe('StylelintLanguageServer', () => {
 		expect(result).toMatchSnapshot();
 	});
 
-	test('if workspace/configuration is not supported, should not register DidChangeConfigurationNotification', () => {
+	test('if workspace/configuration is not supported, should not register DidChangeConfigurationNotification', async () => {
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			logger: mockLogger,
@@ -225,17 +247,17 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{ capabilities: {} } as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
 		const onInitializedHandler = mockNotifications.on.mock.calls.find(
-			(call) => call[0] === InitializedNotification.type,
-		)[1];
+			([type]) => type === InitializedNotification.type,
+		)?.[1];
 
-		onInitializedHandler({});
+		await onInitializedHandler?.({});
 
 		expect(mockLogger.debug).toHaveBeenCalledWith('received onInitialized', { params: {} });
 		expect(mockLogger.debug).not.toHaveBeenCalledWith(
@@ -246,7 +268,7 @@ describe('StylelintLanguageServer', () => {
 		);
 	});
 
-	test('if workspace/configuration is supported, should register DidChangeConfigurationNotification', () => {
+	test('if workspace/configuration is supported, should register DidChangeConfigurationNotification', async () => {
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			logger: mockLogger,
@@ -256,7 +278,7 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{
 				capabilities: {
 					workspace: { configuration: true },
@@ -267,10 +289,10 @@ describe('StylelintLanguageServer', () => {
 		);
 
 		const onInitializedHandler = mockNotifications.on.mock.calls.find(
-			(call) => call[0] === InitializedNotification.type,
-		)[1];
+			([type]) => type === InitializedNotification.type,
+		)?.[1];
 
-		onInitializedHandler({});
+		await onInitializedHandler?.({});
 
 		expect(mockLogger.debug).toHaveBeenCalledWith('received onInitialized', { params: {} });
 		expect(mockLogger.debug).toHaveBeenCalledWith('Registering DidChangeConfigurationNotification');
@@ -280,10 +302,10 @@ describe('StylelintLanguageServer', () => {
 		);
 	});
 
-	test('should display and log errors thrown by module handlers', () => {
+	test('should display and log errors thrown by module handlers', async () => {
 		const error = new Error('Test error');
 
-		class TestModule {
+		class TestModule extends BaseDisposable {
 			static id = 'test-module';
 
 			onInitialize(): Partial<LSP.InitializeResult> {
@@ -301,7 +323,7 @@ describe('StylelintLanguageServer', () => {
 
 		const handler = mockConnection.onInitialize.mock.calls[0][0];
 
-		handler(
+		await handler(
 			{ capabilities: {} } as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
@@ -314,26 +336,13 @@ describe('StylelintLanguageServer', () => {
 		});
 	});
 
-	test('should allow modules to access sibling modules', () => {
-		let module: LanguageServerModule | undefined;
-
-		class TestModuleA {
+	test('should allow modules to access sibling modules', async () => {
+		class TestModuleA extends BaseDisposable {
 			static id = 'module-a';
 			value = 5;
 		}
 
-		class TestModuleB {
-			static id = 'module-b';
-			context: LanguageServerContext;
-
-			constructor({ context }: LanguageServerModuleConstructorParameters) {
-				this.context = context;
-			}
-
-			onInitialize() {
-				module = this.context.getModule('module-a');
-			}
-		}
+		const [TestModuleB, getContext] = getContextModule('module-b');
 
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
@@ -345,26 +354,20 @@ describe('StylelintLanguageServer', () => {
 
 		const handler = mockConnection.onInitialize.mock.calls[0][0];
 
-		handler(
+		await handler(
 			{ capabilities: {} } as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
+		const module = getContext()?.getModule('module-a');
+
 		expect(module).toBeInstanceOf(TestModuleA);
 		expect(module?.value).toBe(5);
 	});
 
 	test('when workspace/configuration is not available, context.getOptions should return global options', async () => {
-		let context: LanguageServerContext | undefined;
-
-		class TestModule {
-			static id = 'test-module';
-
-			constructor(params: LanguageServerModuleConstructorParameters) {
-				context = params.context;
-			}
-		}
+		const [TestModule, getContext] = getContextModule();
 
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
@@ -376,34 +379,26 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{ capabilities: {} } as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
 		const onDidChangeConfigurationHandler = mockNotifications.on.mock.calls.find(
-			(call) => call[0] === DidChangeConfigurationNotification.type,
-		)[1];
+			([type]) => type === DidChangeConfigurationNotification.type,
+		)?.[1];
 
-		onDidChangeConfigurationHandler({ settings: {} });
+		await onDidChangeConfigurationHandler?.({ settings: {} });
 
-		const options = await context?.getOptions?.('uri');
+		const options = await getContext()?.getOptions?.('uri');
 
 		expect(mockConnection.workspace.getConfiguration).not.toHaveBeenCalled();
 		expect(options).toMatchSnapshot();
 	});
 
 	test('when workspace/configuration is not available, context.getOptions should gracefully handle missing section', async () => {
-		let context: LanguageServerContext | undefined;
-
-		class TestModule {
-			static id = 'test-module';
-
-			constructor(params: LanguageServerModuleConstructorParameters) {
-				context = params.context;
-			}
-		}
+		const [TestModule, getContext] = getContextModule();
 
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
@@ -415,19 +410,19 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{ capabilities: {} } as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
-		const initialOptions = await context?.getOptions?.('uri');
+		const initialOptions = await getContext()?.getOptions?.('uri');
 
 		const onDidChangeConfigurationHandler = mockNotifications.on.mock.calls.find(
-			(call) => call[0] === DidChangeConfigurationNotification.type,
-		)[1];
+			([type]) => type === DidChangeConfigurationNotification.type,
+		)?.[1];
 
-		onDidChangeConfigurationHandler({
+		await onDidChangeConfigurationHandler?.({
 			settings: {
 				stylelint: {
 					validate: ['css'],
@@ -435,7 +430,7 @@ describe('StylelintLanguageServer', () => {
 			},
 		});
 
-		const changedOptions = await context?.getOptions?.('uri');
+		const changedOptions = await getContext()?.getOptions?.('uri');
 
 		expect(mockConnection.workspace.getConfiguration).not.toHaveBeenCalled();
 		expect(initialOptions).toMatchSnapshot();
@@ -443,8 +438,12 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('when workspace/configuration is available, context.getOptions should return resource-scoped options', async () => {
+		const [TestModule, getContext] = getContextModule();
+
 		mockConnection.workspace.getConfiguration.mockImplementation(
-			({ scopeUri, section }: LSP.ConfigurationItem) => {
+			async (params: string | LSP.ConfigurationItem | LSP.ConfigurationItem[]) => {
+				const { scopeUri, section } = params as LSP.ConfigurationItem;
+
 				if (section !== 'stylelint') {
 					return {};
 				}
@@ -455,16 +454,6 @@ describe('StylelintLanguageServer', () => {
 			},
 		);
 
-		let context: LanguageServerContext | undefined;
-
-		class TestModule {
-			static id = 'test-module';
-
-			constructor(params: LanguageServerModuleConstructorParameters) {
-				context = params.context;
-			}
-		}
-
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			logger: mockLogger,
@@ -475,7 +464,7 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{
 				capabilities: {
 					workspace: { configuration: true },
@@ -485,8 +474,8 @@ describe('StylelintLanguageServer', () => {
 			{} as WorkDoneProgressReporter,
 		);
 
-		const options1 = await context?.getOptions?.('uri');
-		const options2 = await context?.getOptions?.('uri2');
+		const options1 = await getContext()?.getOptions?.('uri');
+		const options2 = await getContext()?.getOptions?.('uri2');
 
 		expect(mockConnection.workspace.getConfiguration).toHaveBeenCalledWith({
 			scopeUri: 'uri',
@@ -502,7 +491,9 @@ describe('StylelintLanguageServer', () => {
 
 	test('when workspace/configuration is available, context.getOptions should cache resource-scoped options', async () => {
 		mockConnection.workspace.getConfiguration.mockImplementation(
-			({ scopeUri, section }: LSP.ConfigurationItem) => {
+			async (params: string | LSP.ConfigurationItem | LSP.ConfigurationItem[]) => {
+				const { scopeUri, section } = params as LSP.ConfigurationItem;
+
 				if (section !== 'stylelint') {
 					return {};
 				}
@@ -513,15 +504,7 @@ describe('StylelintLanguageServer', () => {
 			},
 		);
 
-		let context: LanguageServerContext | undefined;
-
-		class TestModule {
-			static id = 'test-module';
-
-			constructor(params: LanguageServerModuleConstructorParameters) {
-				context = params.context;
-			}
-		}
+		const [TestModule, getContext] = getContextModule();
 
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
@@ -533,7 +516,7 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{
 				capabilities: {
 					workspace: { configuration: true },
@@ -543,10 +526,10 @@ describe('StylelintLanguageServer', () => {
 			{} as WorkDoneProgressReporter,
 		);
 
-		const options1A = await context?.getOptions?.('uri');
-		const options1B = await context?.getOptions?.('uri');
-		const options2A = await context?.getOptions?.('uri2');
-		const options2B = await context?.getOptions?.('uri2');
+		const options1A = await getContext()?.getOptions?.('uri');
+		const options1B = await getContext()?.getOptions?.('uri');
+		const options2A = await getContext()?.getOptions?.('uri2');
+		const options2B = await getContext()?.getOptions?.('uri2');
 
 		expect(mockConnection.workspace.getConfiguration).toHaveBeenCalledTimes(2);
 		expect(options1A).toEqual(options1B);
@@ -555,7 +538,9 @@ describe('StylelintLanguageServer', () => {
 
 	test('when workspace/configuration is available, on documents.onDidClose, should clear cached options', async () => {
 		mockConnection.workspace.getConfiguration.mockImplementation(
-			({ scopeUri, section }: LSP.ConfigurationItem) => {
+			async (params: string | LSP.ConfigurationItem | LSP.ConfigurationItem[]) => {
+				const { scopeUri, section } = params as LSP.ConfigurationItem;
+
 				if (section !== 'stylelint') {
 					return {};
 				}
@@ -566,15 +551,7 @@ describe('StylelintLanguageServer', () => {
 			},
 		);
 
-		let context: LanguageServerContext | undefined;
-
-		class TestModule {
-			static id = 'test-module';
-
-			constructor(params: LanguageServerModuleConstructorParameters) {
-				context = params.context;
-			}
-		}
+		const [TestModule, getContext] = getContextModule();
 
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
@@ -586,7 +563,7 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{
 				capabilities: {
 					workspace: { configuration: true },
@@ -596,17 +573,17 @@ describe('StylelintLanguageServer', () => {
 			{} as WorkDoneProgressReporter,
 		);
 
-		const options1A = await context?.getOptions?.('uri');
-		const options1B = await context?.getOptions?.('uri');
-		const options2A = await context?.getOptions?.('uri2');
-		const options2B = await context?.getOptions?.('uri2');
+		const options1A = await getContext()?.getOptions?.('uri');
+		const options1B = await getContext()?.getOptions?.('uri');
+		const options2A = await getContext()?.getOptions?.('uri2');
+		const options2B = await getContext()?.getOptions?.('uri2');
 
 		const onDidCloseHandler = mockTextDocuments.mock.results[0].value.onDidClose.mock.calls[0][0];
 
 		onDidCloseHandler({ document: { uri: 'uri' } });
 
-		const options1C = await context?.getOptions?.('uri');
-		const options2C = await context?.getOptions?.('uri2');
+		const options1C = await getContext()?.getOptions?.('uri');
+		const options2C = await getContext()?.getOptions?.('uri2');
 
 		expect(mockConnection.workspace.getConfiguration).toHaveBeenCalledTimes(3);
 		expect(options1A).toEqual(options1B);
@@ -616,15 +593,7 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('when workspace/configuration is available, onDidChangeConfiguration should clear all cached options', async () => {
-		let context: LanguageServerContext | undefined;
-
-		class TestModule {
-			static id = 'test-module';
-
-			constructor(params: LanguageServerModuleConstructorParameters) {
-				context = params.context;
-			}
-		}
+		const [TestModule, getContext] = getContextModule();
 
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
@@ -636,7 +605,7 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{
 				capabilities: {
 					workspace: { configuration: true },
@@ -646,16 +615,16 @@ describe('StylelintLanguageServer', () => {
 			{} as WorkDoneProgressReporter,
 		);
 
-		const options1A = await context?.getOptions?.('uri');
-		const options1B = await context?.getOptions?.('uri');
-		const options2A = await context?.getOptions?.('uri2');
-		const options2B = await context?.getOptions?.('uri2');
+		const options1A = await getContext()?.getOptions?.('uri');
+		const options1B = await getContext()?.getOptions?.('uri');
+		const options2A = await getContext()?.getOptions?.('uri2');
+		const options2B = await getContext()?.getOptions?.('uri2');
 
 		const onDidChangeConfigurationHandler = mockNotifications.on.mock.calls.find(
-			(call) => call[0] === DidChangeConfigurationNotification.type,
-		)[1];
+			([type]) => type === DidChangeConfigurationNotification.type,
+		)?.[1];
 
-		onDidChangeConfigurationHandler({
+		await onDidChangeConfigurationHandler?.({
 			settings: {
 				stylelint: {
 					validate: ['css'],
@@ -663,8 +632,8 @@ describe('StylelintLanguageServer', () => {
 			},
 		});
 
-		const options1C = await context?.getOptions?.('uri');
-		const options2C = await context?.getOptions?.('uri2');
+		const options1C = await getContext()?.getOptions?.('uri');
+		const options2C = await getContext()?.getOptions?.('uri2');
 
 		expect(mockConnection.workspace.getConfiguration).toHaveBeenCalledTimes(4);
 		expect(options1A).toEqual(options1B);
@@ -676,7 +645,7 @@ describe('StylelintLanguageServer', () => {
 	test('when workspace/configuration is available, onDidChangeConfiguration should fire module handlers', async () => {
 		const moduleHandler = jest.fn();
 
-		class TestModule {
+		class TestModule extends BaseDisposable {
 			static id = 'test-module';
 
 			onDidChangeConfiguration = moduleHandler;
@@ -692,7 +661,7 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{
 				capabilities: {
 					workspace: { configuration: true },
@@ -703,10 +672,10 @@ describe('StylelintLanguageServer', () => {
 		);
 
 		const onDidChangeConfigurationHandler = mockNotifications.on.mock.calls.find(
-			(call) => call[0] === DidChangeConfigurationNotification.type,
-		)[1];
+			([type]) => type === DidChangeConfigurationNotification.type,
+		)?.[1];
 
-		onDidChangeConfigurationHandler({
+		await onDidChangeConfigurationHandler?.({
 			settings: {
 				stylelint: {
 					validate: ['css'],
@@ -719,31 +688,13 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('should allow modules to lint documents using context.lintDocument', async () => {
-		let withOptions: Promise<unknown> | undefined;
-		let withoutOptions: Promise<unknown> | undefined;
+		const mockRunnerImpl = serverMocks.getStylelintRunner();
 
-		const mockRunnerImpl = {
-			lintDocument: jest.fn(async () => ['test']),
-		} as unknown as jest.Mocked<StylelintRunner>;
-
-		mockRunner.mockImplementation(() => mockRunnerImpl);
+		mockRunnerImpl.lintDocument.mockResolvedValue(['test'] as unknown as LintDiagnostics);
+		mockRunner.mockImplementation(() => mockRunnerImpl.__typed());
 
 		const document = { uri: 'file:///test.css' } as TextDocument;
-
-		class TestModule {
-			static id = 'test-module';
-			context: LanguageServerContext;
-
-			constructor({ context }: LanguageServerModuleConstructorParameters) {
-				this.context = context;
-			}
-
-			onInitialize() {
-				withOptions = this.context.lintDocument(document, { maxWarnings: 1 });
-				withoutOptions = this.context.lintDocument(document);
-			}
-		}
-
+		const [TestModule, getContext] = getContextModule();
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			modules: [TestModule],
@@ -753,14 +704,17 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{ capabilities: {} } as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
-		expect(await withOptions).toStrictEqual(['test']);
-		expect(await withoutOptions).toStrictEqual(['test']);
+		const withOptions = await getContext()?.lintDocument(document, { maxWarnings: 1 });
+		const withoutOptions = await getContext()?.lintDocument(document);
+
+		expect(withOptions).toStrictEqual(['test']);
+		expect(withoutOptions).toStrictEqual(['test']);
 
 		expect(mockRunnerImpl.lintDocument.mock.calls).toMatchSnapshot();
 	});
@@ -775,7 +729,7 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{
 				capabilities: {
 					workspace: { configuration: true },
@@ -786,10 +740,10 @@ describe('StylelintLanguageServer', () => {
 		);
 
 		const onDidChangeConfigurationHandler = mockNotifications.on.mock.calls.find(
-			(call) => call[0] === DidChangeConfigurationNotification.type,
-		)[1];
+			([type]) => type === DidChangeConfigurationNotification.type,
+		)?.[1];
 
-		onDidChangeConfigurationHandler({
+		await onDidChangeConfigurationHandler?.({
 			settings: {
 				stylelint: {
 					validate: ['css'],
@@ -804,7 +758,6 @@ describe('StylelintLanguageServer', () => {
 
 	test('should display and log errors thrown when linting', async () => {
 		const error = new Error('test');
-		let promise: Promise<unknown> | undefined;
 
 		mockRunner.mockImplementation(
 			() =>
@@ -816,20 +769,7 @@ describe('StylelintLanguageServer', () => {
 		);
 
 		const document = { uri: 'file:///test.css' } as TextDocument;
-
-		class TestModule {
-			static id = 'test-module';
-			context: LanguageServerContext;
-
-			constructor({ context }: LanguageServerModuleConstructorParameters) {
-				this.context = context;
-			}
-
-			onInitialize() {
-				promise = this.context.lintDocument(document, { maxWarnings: 1 });
-			}
-		}
-
+		const [TestModule, getContext] = getContextModule();
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			logger: mockLogger,
@@ -840,13 +780,15 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{ capabilities: {} } as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
-		expect(await promise).toBeUndefined();
+		const results = await getContext()?.lintDocument(document, { maxWarnings: 1 });
+
+		expect(results).toBeUndefined();
 		expect(mockDisplayError).toHaveBeenCalledWith(mockConnection, error);
 		expect(mockLogger.error).toHaveBeenCalledWith('Error running lint', {
 			uri: 'file:///test.css',
@@ -855,28 +797,11 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('should allow modules to get fixes for documents using context.getFixes', async () => {
-		let withOptions: Promise<unknown> | undefined;
-		let withoutOptions: Promise<unknown> | undefined;
-
 		mockRunner.mockImplementation(() => ({} as unknown as StylelintRunner));
 		mockGetFixes.mockImplementation(async () => ['test'] as unknown as TextEdit[]);
 
 		const document = { uri: 'file:///test.css' } as TextDocument;
-
-		class TestModule {
-			static id = 'test-module';
-			context: LanguageServerContext;
-
-			constructor({ context }: LanguageServerModuleConstructorParameters) {
-				this.context = context;
-			}
-
-			onInitialize() {
-				withOptions = this.context.getFixes(document, { maxWarnings: 1 });
-				withoutOptions = this.context.getFixes(document);
-			}
-		}
-
+		const [TestModule, getContext] = getContextModule();
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			modules: [TestModule],
@@ -886,40 +811,29 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{ capabilities: {} } as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
-		expect(await withOptions).toStrictEqual(['test']);
-		expect(await withoutOptions).toStrictEqual(['test']);
+		const withOptions = await getContext()?.getFixes(document, { maxWarnings: 1 });
+		const withoutOptions = await getContext()?.getFixes(document);
+
+		expect(withOptions).toStrictEqual(['test']);
+		expect(withoutOptions).toStrictEqual(['test']);
 		expect(mockGetFixes.mock.calls).toMatchSnapshot();
 	});
 
 	test('should display and log errors thrown when getting fixes', async () => {
 		const error = new Error('test');
-		let promise: Promise<unknown> | undefined;
 
 		mockGetFixes.mockImplementation(() => {
 			throw error;
 		});
 
 		const document = { uri: 'file:///test.css' } as TextDocument;
-
-		class TestModule {
-			static id = 'test-module';
-			context: LanguageServerContext;
-
-			constructor({ context }: LanguageServerModuleConstructorParameters) {
-				this.context = context;
-			}
-
-			onInitialize() {
-				promise = this.context.getFixes(document, { maxWarnings: 1 });
-			}
-		}
-
+		const [TestModule, getContext] = getContextModule();
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			logger: mockLogger,
@@ -930,13 +844,15 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{ capabilities: {} } as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
-		expect(await promise).toStrictEqual([]);
+		const fixes = await getContext()?.getFixes(document, { maxWarnings: 1 });
+
+		expect(fixes).toStrictEqual([]);
 		expect(mockDisplayError).toHaveBeenCalledWith(mockConnection, error);
 		expect(mockLogger.error).toHaveBeenCalledWith('Error getting fixes', {
 			uri: 'file:///test.css',
@@ -945,8 +861,6 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('should allow modules to resolve the Stylelint package for a given document using context.resolveStylelintPackage', async () => {
-		let promise: Promise<unknown> | undefined;
-
 		mockResolver.mockImplementation(
 			() =>
 				({
@@ -958,20 +872,7 @@ describe('StylelintLanguageServer', () => {
 		);
 
 		const document = { uri: 'file:///test.css' } as TextDocument;
-
-		class TestModule {
-			static id = 'test-module';
-			context: LanguageServerContext;
-
-			constructor({ context }: LanguageServerModuleConstructorParameters) {
-				this.context = context;
-			}
-
-			onInitialize() {
-				promise = this.context.resolveStylelint(document);
-			}
-		}
-
+		const [TestModule, getContext] = getContextModule();
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			modules: [TestModule],
@@ -981,13 +882,15 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{ capabilities: {} } as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
-		expect(await promise).toStrictEqual({
+		const resolved = await getContext()?.resolveStylelint(document);
+
+		expect(resolved).toStrictEqual({
 			stylelint: { fake: 'package' },
 			resolvedPath: 'fake/path',
 		});
@@ -995,7 +898,6 @@ describe('StylelintLanguageServer', () => {
 
 	test('should display and log errors thrown when resolving the Stylelint package', async () => {
 		const error = new Error('test');
-		let promise: Promise<unknown> | undefined;
 
 		mockResolver.mockImplementation(
 			() =>
@@ -1007,20 +909,7 @@ describe('StylelintLanguageServer', () => {
 		);
 
 		const document = { uri: 'file:///test.css' } as TextDocument;
-
-		class TestModule {
-			static id = 'test-module';
-			context: LanguageServerContext;
-
-			constructor({ context }: LanguageServerModuleConstructorParameters) {
-				this.context = context;
-			}
-
-			onInitialize() {
-				promise = this.context.resolveStylelint(document);
-			}
-		}
-
+		const [TestModule, getContext] = getContextModule();
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			logger: mockLogger,
@@ -1031,13 +920,15 @@ describe('StylelintLanguageServer', () => {
 
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
-		onInitializeHandler(
+		await onInitializeHandler(
 			{ capabilities: {} } as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
-		expect(await promise).toBeUndefined();
+		const resolved = await getContext()?.resolveStylelint(document);
+
+		expect(resolved).toBeUndefined();
 		expect(mockDisplayError).toHaveBeenCalledWith(mockConnection, error);
 		expect(mockLogger.error).toHaveBeenCalledWith('Error resolving Stylelint', {
 			uri: 'file:///test.css',
@@ -1046,8 +937,6 @@ describe('StylelintLanguageServer', () => {
 	});
 
 	test('should log when Stylelint cannot be resolved', async () => {
-		let promise: Promise<unknown> | undefined;
-
 		mockResolver.mockImplementation(
 			() =>
 				({
@@ -1058,39 +947,286 @@ describe('StylelintLanguageServer', () => {
 		);
 
 		const document = { uri: 'file:///test.css' } as TextDocument;
+		const [TestModule, getContext] = getContextModule();
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+			modules: [TestModule],
+		});
 
-		class TestModule {
-			static id = 'test-module';
-			context: LanguageServerContext;
+		server.start();
 
-			constructor({ context }: LanguageServerModuleConstructorParameters) {
-				this.context = context;
+		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
+
+		await onInitializeHandler(
+			{ capabilities: {} } as LSP.InitializeParams,
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		const resolved = await getContext()?.resolveStylelint(document);
+
+		expect(resolved).toBeUndefined();
+		expect(mockLogger.warn).toHaveBeenCalledWith('Failed to resolve Stylelint', {
+			uri: 'file:///test.css',
+		});
+	});
+
+	it('should be disposable', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		expect(server).toHaveProperty('dispose');
+		expect(server.dispose).toBeInstanceOf(Function);
+	});
+
+	it('should log when disposing', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.dispose();
+
+		expect(mockLogger.info).toHaveBeenCalledWith('Stopping language server');
+	});
+
+	it('should dispose all modules when disposed', () => {
+		const funcs: jest.Mock[] = [];
+
+		class TestModuleA extends BaseDisposable {
+			static id = 'module-a';
+
+			constructor() {
+				const dispose = jest.fn();
+
+				super();
+				funcs.push(dispose);
+				this.dispose = dispose;
 			}
+		}
 
-			onInitialize() {
-				promise = this.context.resolveStylelint(document);
+		class TestModuleB extends BaseDisposable {
+			static id = 'module-b';
+
+			constructor() {
+				const dispose = jest.fn();
+
+				super();
+				funcs.push(dispose);
+				this.dispose = dispose;
 			}
 		}
 
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			logger: mockLogger,
-			modules: [TestModule],
+			modules: [TestModuleA, TestModuleB],
+		});
+
+		server.dispose();
+
+		expect(funcs).toHaveLength(2);
+		expect(funcs[0]).toHaveBeenCalledTimes(1);
+		expect(funcs[1]).toHaveBeenCalledTimes(1);
+	});
+
+	it('should set a no-op InitializeRequest handler when disposed', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.dispose();
+
+		expect(mockConnection.onInitialize).toHaveBeenCalledTimes(1);
+		expect(mockConnection.onInitialize.mock.calls[0][0]).toBeInstanceOf(Function);
+		expect(
+			mockConnection.onInitialize.mock.calls[0][0](
+				{
+					rootUri: '',
+					capabilities: {},
+					processId: 1,
+					workspaceFolders: [],
+				},
+				{} as LSP.CancellationToken,
+				{} as WorkDoneProgressReporter,
+			),
+		).toStrictEqual({
+			capabilities: {},
+		});
+	});
+
+	it('should set a no-op ShutdownRequest handler when disposed', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.dispose();
+
+		expect(mockConnection.onShutdown).toHaveBeenCalledTimes(1);
+		expect(mockConnection.onShutdown.mock.calls[0][0]).toBeInstanceOf(Function);
+		expect(mockConnection.onShutdown.mock.calls[0][0]({} as LSP.CancellationToken)).toBeUndefined();
+	});
+
+	it('should dispose all handler registrations when disposed', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
 		});
 
 		server.start();
+		server.dispose();
 
-		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
+		const disposables = [
+			...mockNotifications.on.mock.results,
+			...mockDocuments.onDidClose.mock.results,
+		];
 
-		onInitializeHandler(
-			{ capabilities: {} } as LSP.InitializeParams,
+		expect(disposables).toHaveLength(3);
+
+		for (const disposable of disposables) {
+			expect(disposable.value.dispose).toHaveBeenCalledTimes(1);
+		}
+	});
+
+	it('should dispose the notification manager when disposed', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.dispose();
+
+		expect(mockNotifications.dispose).toHaveBeenCalled();
+	});
+
+	it('should dispose the command manager when disposed', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.dispose();
+
+		expect(mockCommands.dispose).toHaveBeenCalled();
+	});
+
+	it('should dispose the connection when disposed', async () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.start();
+
+		const onInitializedHandler = mockNotifications.on.mock.calls.find(
+			([type]) => type === InitializedNotification.type,
+		)?.[1];
+
+		await onInitializedHandler?.(
+			{} as LSP.InitializedParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
-		expect(await promise).toBeUndefined();
-		expect(mockLogger.warn).toHaveBeenCalledWith('Failed to resolve Stylelint', {
-			uri: 'file:///test.css',
+		server.dispose();
+
+		expect(mockConnection.dispose).toHaveBeenCalled();
+	});
+
+	it('should not allow starting the server twice', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.start();
+
+		expect(() => server.start()).toThrowErrorMatchingInlineSnapshot(
+			`"Cannot transition from state Started to itself"`,
+		);
+	});
+
+	it('should not allow starting the server once initialized', async () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
 		});
+
+		server.start();
+
+		const onInitializedHandler = mockNotifications.on.mock.calls.find(
+			([type]) => type === InitializedNotification.type,
+		)?.[1];
+
+		await onInitializedHandler?.(
+			{} as LSP.InitializedParams,
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		expect(() => server.start()).toThrowErrorMatchingInlineSnapshot(
+			`"Can only transition state from Initialized to Disposed"`,
+		);
+	});
+
+	it('should not allow starting the server once disposed', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.start();
+		server.dispose();
+
+		expect(() => server.start()).toThrowErrorMatchingInlineSnapshot(
+			`"Cannot transition from Disposed"`,
+		);
+	});
+
+	it('should not throw when calling dispose twice', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.dispose();
+
+		expect(() => server.dispose()).not.toThrow();
+	});
+
+	it('should dispose the server after receiving a shutdown request', async () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.start();
+
+		const onInitializedHandler = mockNotifications.on.mock.calls.find(
+			([type]) => type === InitializedNotification.type,
+		)?.[1];
+
+		await onInitializedHandler?.(
+			{} as LSP.InitializedParams,
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		const onShutdownHandler = mockConnection.onShutdown.mock.calls[0][0];
+
+		await onShutdownHandler({} as LSP.CancellationToken);
+
+		expect(mockLogger.info).toHaveBeenCalledWith('Stopping language server');
+		expect(mockConnection.dispose).toHaveBeenCalledTimes(1);
+		expect(mockNotifications.dispose).toHaveBeenCalledTimes(1);
+		expect(mockCommands.dispose).toHaveBeenCalledTimes(1);
+		expect(() => server.start()).toThrowErrorMatchingInlineSnapshot(
+			`"Cannot transition from Disposed"`,
+		);
 	});
 });
diff --git a/src/server/modules/__tests__/__snapshots__/formatter.ts.snap b/src/server/modules/__tests__/__snapshots__/formatter.ts.snap
index 97260a5..a58ef49 100644
--- a/src/server/modules/__tests__/__snapshots__/formatter.ts.snap
+++ b/src/server/modules/__tests__/__snapshots__/formatter.ts.snap
@@ -20,9 +20,12 @@ Array [
 
 exports[`FormatterModule should format documents 2`] = `
 Array [
-  Object {
-    "languageId": "bar",
-    "uri": "foo",
+  FullTextDocument {
+    "_content": "a {}",
+    "_languageId": "bar",
+    "_lineOffsets": undefined,
+    "_uri": "foo",
+    "_version": 1,
   },
   Object {
     "config": Object {
diff --git a/src/server/modules/__tests__/auto-fix.ts b/src/server/modules/__tests__/auto-fix.ts
index dcbf814..d4acc51 100644
--- a/src/server/modules/__tests__/auto-fix.ts
+++ b/src/server/modules/__tests__/auto-fix.ts
@@ -1,7 +1,10 @@
+import { TextDocument } from 'vscode-languageserver-textdocument';
 import { Position, TextEdit } from 'vscode-languageserver-types';
+import type LSP from 'vscode-languageserver-protocol';
 import { CommandId } from '../../types';
 
 import { AutoFixModule } from '../auto-fix';
+import { WorkDoneProgressReporter } from 'vscode-languageserver';
 
 const mockContext = serverMocks.getContext();
 const mockLogger = serverMocks.getLogger();
@@ -35,13 +38,11 @@ describe('AutoFixModule', () => {
 	});
 
 	test('should auto-fix documents', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-			version: 1,
-		});
+		const document = TextDocument.create('foo', 'bar', 1, 'a {}');
+
+		mockContext.documents.get.mockReturnValue(document);
 		mockContext.__options.validate = ['bar'];
-		mockContext.getFixes.mockReturnValue([TextEdit.insert(Position.create(0, 0), 'text')]);
+		mockContext.getFixes.mockResolvedValue([TextEdit.insert(Position.create(0, 0), 'text')]);
 		mockContext.connection.workspace.applyEdit.mockResolvedValue({ applied: true });
 
 		const module = new AutoFixModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -50,19 +51,19 @@ describe('AutoFixModule', () => {
 
 		const handler = mockContext.commands.on.mock.calls.find(
 			([command]) => command === CommandId.ApplyAutoFix,
-		)[1];
+		)?.[1];
 
-		const result = await handler({
-			command: CommandId.ApplyAutoFix,
-			arguments: [{ uri: 'foo', version: 1 }],
-		});
+		const result = await handler?.(
+			{
+				command: CommandId.ApplyAutoFix,
+				arguments: [{ uri: 'foo', version: 1 }],
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual({});
-		expect(mockContext.getFixes).toHaveBeenCalledWith({
-			languageId: 'bar',
-			uri: 'foo',
-			version: 1,
-		});
+		expect(mockContext.getFixes).toHaveBeenCalledWith(document);
 		expect(mockContext.connection.workspace.applyEdit.mock.calls[0]).toMatchSnapshot();
 	});
 
@@ -73,9 +74,13 @@ describe('AutoFixModule', () => {
 
 		const handler = mockContext.commands.on.mock.calls.find(
 			([command]) => command === CommandId.ApplyAutoFix,
-		)[1];
+		)?.[1];
 
-		const result = await handler({ command: 'foo' });
+		const result = await handler?.(
+			{ command: 'foo' },
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual({});
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -88,9 +93,13 @@ describe('AutoFixModule', () => {
 
 		const handler = mockContext.commands.on.mock.calls.find(
 			([command]) => command === CommandId.ApplyAutoFix,
-		)[1];
+		)?.[1];
 
-		const result = await handler({ command: CommandId.ApplyAutoFix });
+		const result = await handler?.(
+			{ command: CommandId.ApplyAutoFix },
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual({});
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -105,12 +114,16 @@ describe('AutoFixModule', () => {
 
 		const handler = mockContext.commands.on.mock.calls.find(
 			([command]) => command === CommandId.ApplyAutoFix,
-		)[1];
+		)?.[1];
 
-		const result = await handler({
-			command: CommandId.ApplyAutoFix,
-			arguments: [{ uri: 'foo' }],
-		});
+		const result = await handler?.(
+			{
+				command: CommandId.ApplyAutoFix,
+				arguments: [{ uri: 'foo' }],
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual({});
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -118,10 +131,7 @@ describe('AutoFixModule', () => {
 	});
 
 	test('if document language ID is not in options, should not attempt to auto-fix', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['baz'];
 
 		const module = new AutoFixModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -130,12 +140,16 @@ describe('AutoFixModule', () => {
 
 		const handler = mockContext.commands.on.mock.calls.find(
 			([command]) => command === CommandId.ApplyAutoFix,
-		)[1];
+		)?.[1];
 
-		const result = await handler({
-			command: CommandId.ApplyAutoFix,
-			arguments: [{ uri: 'foo' }],
-		});
+		const result = await handler?.(
+			{
+				command: CommandId.ApplyAutoFix,
+				arguments: [{ uri: 'foo' }],
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual({});
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -149,10 +163,7 @@ describe('AutoFixModule', () => {
 	});
 
 	test('with no debug log level and no valid document, should not attempt to log reason', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['baz'];
 		mockLogger.isDebugEnabled.mockReturnValue(false);
 
@@ -162,14 +173,18 @@ describe('AutoFixModule', () => {
 
 		const handler = mockContext.commands.on.mock.calls.find(
 			([command]) => command === CommandId.ApplyAutoFix,
-		)[1];
+		)?.[1];
 
 		const handlerParams = {
 			command: CommandId.ApplyAutoFix,
 			arguments: [{ uri: 'foo' }],
 		};
 
-		const result = await handler(handlerParams);
+		const result = await handler?.(
+			handlerParams,
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual({});
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -177,11 +192,7 @@ describe('AutoFixModule', () => {
 	});
 
 	test('if the document has been modified, should not attempt to auto-fix', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-			version: 2,
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 2, 'a {}'));
 		mockContext.__options.validate = ['bar'];
 
 		const module = new AutoFixModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -190,12 +201,16 @@ describe('AutoFixModule', () => {
 
 		const handler = mockContext.commands.on.mock.calls.find(
 			([command]) => command === CommandId.ApplyAutoFix,
-		)[1];
+		)?.[1];
 
-		const result = await handler({
-			command: CommandId.ApplyAutoFix,
-			arguments: [{ uri: 'foo', version: 1 }],
-		});
+		const result = await handler?.(
+			{
+				command: CommandId.ApplyAutoFix,
+				arguments: [{ uri: 'foo', version: 1 }],
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual({});
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -207,13 +222,9 @@ describe('AutoFixModule', () => {
 	test('if fixes fail to apply, should log the response', async () => {
 		const response = { applied: false, failureReason: 'foo' };
 
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-			version: 1,
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['bar'];
-		mockContext.getFixes.mockReturnValue([TextEdit.insert(Position.create(0, 0), 'text')]);
+		mockContext.getFixes.mockResolvedValue([TextEdit.insert(Position.create(0, 0), 'text')]);
 		mockContext.connection.workspace.applyEdit.mockResolvedValue(response);
 
 		const module = new AutoFixModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -222,12 +233,16 @@ describe('AutoFixModule', () => {
 
 		const handler = mockContext.commands.on.mock.calls.find(
 			([command]) => command === CommandId.ApplyAutoFix,
-		)[1];
+		)?.[1];
 
-		const result = await handler({
-			command: CommandId.ApplyAutoFix,
-			arguments: [{ uri: 'foo', version: 1 }],
-		});
+		const result = await handler?.(
+			{
+				command: CommandId.ApplyAutoFix,
+				arguments: [{ uri: 'foo', version: 1 }],
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual({});
 		expect(mockLogger.debug).toHaveBeenLastCalledWith('Failed to apply fixes', {
@@ -239,13 +254,9 @@ describe('AutoFixModule', () => {
 	test('if an error occurs while applying fixes, should log it', async () => {
 		const error = new Error('foo');
 
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-			version: 1,
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['bar'];
-		mockContext.getFixes.mockReturnValue([TextEdit.insert(Position.create(0, 0), 'text')]);
+		mockContext.getFixes.mockResolvedValue([TextEdit.insert(Position.create(0, 0), 'text')]);
 		mockContext.connection.workspace.applyEdit.mockRejectedValue(error);
 
 		const module = new AutoFixModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -254,12 +265,16 @@ describe('AutoFixModule', () => {
 
 		const handler = mockContext.commands.on.mock.calls.find(
 			([command]) => command === CommandId.ApplyAutoFix,
-		)[1];
+		)?.[1];
 
-		const result = await handler({
-			command: CommandId.ApplyAutoFix,
-			arguments: [{ uri: 'foo', version: 1 }],
-		});
+		const result = await handler?.(
+			{
+				command: CommandId.ApplyAutoFix,
+				arguments: [{ uri: 'foo', version: 1 }],
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual({});
 		expect(mockLogger.debug).toHaveBeenLastCalledWith('Failed to apply fixes', {
@@ -267,4 +282,22 @@ describe('AutoFixModule', () => {
 			error,
 		});
 	});
+
+	it('should be disposable', () => {
+		const module = new AutoFixModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		expect(module).toHaveProperty('dispose');
+		expect(module.dispose).toBeInstanceOf(Function);
+	});
+
+	it('should dispose all handler registrations when disposed', () => {
+		const module = new AutoFixModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		module.onDidRegisterHandlers();
+		module.dispose();
+
+		const disposable = mockContext.commands.on.mock.results[0].value;
+
+		expect(disposable.dispose).toHaveBeenCalledTimes(1);
+	});
 });
diff --git a/src/server/modules/__tests__/code-action.ts b/src/server/modules/__tests__/code-action.ts
index 72a17e7..35258bc 100644
--- a/src/server/modules/__tests__/code-action.ts
+++ b/src/server/modules/__tests__/code-action.ts
@@ -4,6 +4,7 @@ import { CodeActionKind as StylelintCodeActionKind } from '../../types';
 
 import { CodeActionModule } from '../code-action';
 import { CommandId, Notification } from '../..';
+import { WorkDoneProgressReporter } from 'vscode-languageserver';
 
 const mockContext = serverMocks.getContext();
 const mockLogger = serverMocks.getLogger();
@@ -44,12 +45,12 @@ describe('CodeActionModule', () => {
 		);
 	});
 
-	test('should send the DidRegisterCodeActionRequestHandler notification when InitializedNotification is received', () => {
+	test('should send the DidRegisterCodeActionRequestHandler notification when InitializedNotification is received', async () => {
 		const module = new CodeActionModule({ context: mockContext.__typed() });
 
 		module.onDidRegisterHandlers();
 
-		mockContext.notifications.on.mock.calls[0][1]();
+		await mockContext.notifications.on.mock.calls[0][1]?.();
 
 		expect(mockContext.connection.sendNotification).toHaveBeenCalledWith(
 			Notification.DidRegisterCodeActionRequestHandler,
@@ -61,7 +62,9 @@ describe('CodeActionModule', () => {
 			TextDocument.create('foo', 'bar', 1, 'line 1\nline 2'),
 		);
 		mockContext.__options.validate = ['bar'];
-		mockContext.getFixes.mockReturnValue([LSP.TextEdit.insert(LSP.Position.create(0, 0), 'text')]);
+		mockContext.getFixes.mockResolvedValue([
+			LSP.TextEdit.insert(LSP.Position.create(0, 0), 'text'),
+		]);
 
 		const module = new CodeActionModule({ context: mockContext.__typed(), logger: mockLogger });
 
@@ -69,10 +72,15 @@ describe('CodeActionModule', () => {
 
 		const handler = mockContext.connection.onCodeAction.mock.calls[0][0];
 
-		const result = await handler({
-			context: { only: [LSP.CodeActionKind.Source] },
-			textDocument: { uri: 'foo' },
-		});
+		const result = await handler(
+			{
+				context: { only: [LSP.CodeActionKind.Source], diagnostics: [] },
+				textDocument: { uri: 'foo' },
+				range: LSP.Range.create(LSP.Position.create(0, 0), LSP.Position.create(0, 0)),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toMatchSnapshot();
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -83,7 +91,9 @@ describe('CodeActionModule', () => {
 
 		mockContext.documents.get.mockReturnValue(document);
 		mockContext.__options.validate = ['bar'];
-		mockContext.getFixes.mockReturnValue([LSP.TextEdit.insert(LSP.Position.create(0, 0), 'text')]);
+		mockContext.getFixes.mockResolvedValue([
+			LSP.TextEdit.insert(LSP.Position.create(0, 0), 'text'),
+		]);
 
 		const module = new CodeActionModule({ context: mockContext.__typed(), logger: mockLogger });
 
@@ -91,10 +101,15 @@ describe('CodeActionModule', () => {
 
 		const handler = mockContext.connection.onCodeAction.mock.calls[0][0];
 
-		const result = await handler({
-			context: { only: [StylelintCodeActionKind.StylelintSourceFixAll] },
-			textDocument: { uri: 'foo' },
-		});
+		const result = await handler(
+			{
+				context: { only: [StylelintCodeActionKind.StylelintSourceFixAll], diagnostics: [] },
+				textDocument: { uri: 'foo' },
+				range: LSP.Range.create(LSP.Position.create(0, 0), LSP.Position.create(0, 0)),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toMatchSnapshot();
 		expect(mockContext.getFixes).toHaveBeenCalledWith(document);
@@ -105,7 +120,9 @@ describe('CodeActionModule', () => {
 
 		mockContext.documents.get.mockReturnValue(document);
 		mockContext.__options.validate = ['bar'];
-		mockContext.getFixes.mockReturnValue([LSP.TextEdit.insert(LSP.Position.create(0, 0), 'text')]);
+		mockContext.getFixes.mockResolvedValue([
+			LSP.TextEdit.insert(LSP.Position.create(0, 0), 'text'),
+		]);
 
 		const module = new CodeActionModule({ context: mockContext.__typed(), logger: mockLogger });
 
@@ -113,10 +130,15 @@ describe('CodeActionModule', () => {
 
 		const handler = mockContext.connection.onCodeAction.mock.calls[0][0];
 
-		const result = await handler({
-			context: { only: [LSP.CodeActionKind.SourceFixAll] },
-			textDocument: { uri: 'foo' },
-		});
+		const result = await handler(
+			{
+				context: { only: [LSP.CodeActionKind.SourceFixAll], diagnostics: [] },
+				textDocument: { uri: 'foo' },
+				range: LSP.Range.create(LSP.Position.create(0, 0), LSP.Position.create(0, 0)),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toMatchSnapshot();
 		expect(mockContext.getFixes).toHaveBeenCalledWith(document);
@@ -127,7 +149,7 @@ describe('CodeActionModule', () => {
 
 		mockContext.documents.get.mockReturnValue(document);
 		mockContext.__options.validate = ['bar'];
-		mockContext.getFixes.mockReturnValue([]);
+		mockContext.getFixes.mockResolvedValue([]);
 
 		const module = new CodeActionModule({ context: mockContext.__typed(), logger: mockLogger });
 
@@ -135,10 +157,15 @@ describe('CodeActionModule', () => {
 
 		const handler = mockContext.connection.onCodeAction.mock.calls[0][0];
 
-		const result = await handler({
-			context: { only: [LSP.CodeActionKind.SourceFixAll] },
-			textDocument: { uri: 'foo' },
-		});
+		const result = await handler(
+			{
+				context: { only: [LSP.CodeActionKind.SourceFixAll], diagnostics: [] },
+				textDocument: { uri: 'foo' },
+				range: LSP.Range.create(LSP.Position.create(0, 0), LSP.Position.create(0, 0)),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual([]);
 		expect(mockContext.getFixes).toHaveBeenCalledWith(document);
@@ -204,7 +231,15 @@ describe('CodeActionModule', () => {
 			],
 		};
 
-		const result = await handler({ context, textDocument: { uri: 'foo' } });
+		const result = await handler(
+			{
+				context,
+				textDocument: { uri: 'foo' },
+				range: LSP.Range.create(LSP.Position.create(0, 0), LSP.Position.create(0, 1)),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toMatchSnapshot();
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -224,12 +259,18 @@ describe('CodeActionModule', () => {
 
 		const handler = mockContext.connection.onCodeAction.mock.calls[0][0];
 
-		const result = await handler({
-			context: {
-				only: ['foo'],
+		const result = await handler(
+			{
+				context: {
+					only: ['foo'],
+					diagnostics: [],
+				},
+				textDocument: { uri: 'foo' },
+				range: LSP.Range.create(LSP.Position.create(0, 0), LSP.Position.create(0, 0)),
 			},
-			textDocument: { uri: 'foo' },
-		});
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual([]);
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -248,10 +289,15 @@ describe('CodeActionModule', () => {
 
 		const handler = mockContext.connection.onCodeAction.mock.calls[0][0];
 
-		const result = await handler({
-			context: { only: [LSP.CodeActionKind.Source] },
-			textDocument: { uri: 'foo' },
-		});
+		const result = await handler(
+			{
+				context: { only: [LSP.CodeActionKind.Source], diagnostics: [] },
+				textDocument: { uri: 'foo' },
+				range: LSP.Range.create(LSP.Position.create(0, 0), LSP.Position.create(0, 0)),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual([]);
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -259,10 +305,9 @@ describe('CodeActionModule', () => {
 	});
 
 	test('if document language ID is not in options, should not attempt to create actions', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-		});
+		mockContext.documents.get.mockReturnValue(
+			TextDocument.create('foo', 'bar', 1, 'line 1\nline 2'),
+		);
 		mockContext.__options.validate = ['baz'];
 
 		const module = new CodeActionModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -271,10 +316,15 @@ describe('CodeActionModule', () => {
 
 		const handler = mockContext.connection.onCodeAction.mock.calls[0][0];
 
-		const result = await handler({
-			context: { only: [LSP.CodeActionKind.Source] },
-			textDocument: { uri: 'foo' },
-		});
+		const result = await handler(
+			{
+				context: { only: [LSP.CodeActionKind.Source], diagnostics: [] },
+				textDocument: { uri: 'foo' },
+				range: LSP.Range.create(LSP.Position.create(0, 0), LSP.Position.create(0, 0)),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual([]);
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -299,13 +349,18 @@ describe('CodeActionModule', () => {
 				([command]) => command === CommandId.OpenRuleDoc,
 			)?.[1];
 
-			const result = await handler({
-				arguments: [
-					{
-						uri: 'https://stylelint.io/user-guide/rules/foo',
-					},
-				],
-			});
+			const result = await handler?.(
+				{
+					command: CommandId.OpenRuleDoc,
+					arguments: [
+						{
+							uri: 'https://stylelint.io/user-guide/rules/foo',
+						},
+					],
+				},
+				{} as LSP.CancellationToken,
+				{} as WorkDoneProgressReporter,
+			);
 
 			expect(result).toStrictEqual({});
 			expect(mockContext.connection.window.showDocument).toHaveBeenCalledWith({
@@ -327,7 +382,11 @@ describe('CodeActionModule', () => {
 				([command]) => command === CommandId.OpenRuleDoc,
 			)?.[1];
 
-			const result = await handler({});
+			const result = await handler?.(
+				{ command: CommandId.OpenRuleDoc },
+				{} as LSP.CancellationToken,
+				{} as WorkDoneProgressReporter,
+			);
 
 			expect(result).toStrictEqual({});
 			expect(mockContext.connection.window.showDocument).not.toHaveBeenCalled();
@@ -348,13 +407,18 @@ describe('CodeActionModule', () => {
 				([command]) => command === CommandId.OpenRuleDoc,
 			)?.[1];
 
-			const result = await handler({
-				arguments: [
-					{
-						uri: 'https://stylelint.io/user-guide/rules/foo',
-					},
-				],
-			});
+			const result = await handler?.(
+				{
+					command: CommandId.OpenRuleDoc,
+					arguments: [
+						{
+							uri: 'https://stylelint.io/user-guide/rules/foo',
+						},
+					],
+				},
+				{} as LSP.CancellationToken,
+				{} as WorkDoneProgressReporter,
+			);
 
 			expect(result).toStrictEqual(
 				new LSP.ResponseError(LSP.ErrorCodes.InternalError, 'Failed to open rule documentation'),
@@ -371,4 +435,52 @@ describe('CodeActionModule', () => {
 			});
 		});
 	});
+
+	it('should be disposable', () => {
+		const module = new CodeActionModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		expect(module).toHaveProperty('dispose');
+		expect(module.dispose).toBeInstanceOf(Function);
+	});
+
+	it('should set a no-op code action handler when disposed', async () => {
+		const module = new CodeActionModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		module.onDidRegisterHandlers();
+		module.dispose();
+
+		const handler = mockContext.connection.onCodeAction.mock.calls[1][0];
+
+		const result = await handler(
+			{
+				context: {
+					diagnostics: [],
+					only: [LSP.CodeActionKind.QuickFix],
+				},
+				textDocument: { uri: 'foo' },
+				range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		expect(mockContext.connection.onCodeAction).toHaveBeenCalledTimes(2);
+		expect(result).toBeUndefined();
+	});
+
+	it('should dispose all handler registrations when disposed', () => {
+		const module = new CodeActionModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		module.onDidRegisterHandlers();
+		module.dispose();
+
+		const disposables = [
+			...mockContext.notifications.on.mock.results,
+			...mockContext.commands.on.mock.results,
+		];
+
+		expect(disposables).toHaveLength(2);
+		expect(disposables[0].value.dispose).toHaveBeenCalledTimes(1);
+		expect(disposables[1].value.dispose).toHaveBeenCalledTimes(1);
+	});
 });
diff --git a/src/server/modules/__tests__/completion.ts b/src/server/modules/__tests__/completion.ts
index 8cee877..65015f5 100644
--- a/src/server/modules/__tests__/completion.ts
+++ b/src/server/modules/__tests__/completion.ts
@@ -4,6 +4,7 @@ import type LSP from 'vscode-languageserver-protocol';
 import { DisableReportRuleNames } from '../../../utils/stylelint';
 
 import { CompletionModule } from '../completion';
+import { WorkDoneProgressReporter } from 'vscode-languageserver';
 
 const mockContext = serverMocks.getContext();
 const mockLogger = serverMocks.getLogger();
@@ -58,10 +59,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'foo' },
-			position: Position.create(0, 0),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'foo' },
+				position: Position.create(0, 0),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual([]);
 		expect(mockContext.getModule).not.toHaveBeenCalled();
@@ -69,10 +74,7 @@ describe('CompletionModule', () => {
 	});
 
 	test('if document language ID is not in validate options, should not return completions', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['baz'];
 		mockContext.__options.snippet = ['bar'];
 
@@ -82,10 +84,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'foo' },
-			position: Position.create(0, 0),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'foo' },
+				position: Position.create(0, 0),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual([]);
 		expect(mockContext.getModule).not.toHaveBeenCalled();
@@ -99,10 +105,7 @@ describe('CompletionModule', () => {
 	});
 
 	test('if document language ID is not in snippet options, should not return completions', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['bar'];
 		mockContext.__options.snippet = ['baz'];
 
@@ -112,10 +115,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'foo' },
-			position: Position.create(0, 0),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'foo' },
+				position: Position.create(0, 0),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual([]);
 		expect(mockContext.getModule).not.toHaveBeenCalled();
@@ -129,10 +136,7 @@ describe('CompletionModule', () => {
 	});
 
 	test('with no debug log level and no valid document, should not attempt to log reason', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['bar'];
 		mockContext.__options.snippet = ['baz'];
 		mockLogger.isDebugEnabled.mockReturnValue(false);
@@ -143,10 +147,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'foo' },
-			position: Position.create(0, 0),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'foo' },
+				position: Position.create(0, 0),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toStrictEqual([]);
 		expect(mockContext.getModule).not.toHaveBeenCalled();
@@ -157,10 +165,7 @@ describe('CompletionModule', () => {
 	});
 
 	test('without the validator module, should return generic completions', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['bar'];
 		mockContext.__options.snippet = ['bar'];
 		mockContext.getModule.mockReturnValue(undefined);
@@ -171,24 +176,26 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'foo' },
-			position: Position.create(0, 0),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'foo' },
+				position: Position.create(0, 0),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(mockContext.getModule).toHaveBeenCalledTimes(1);
 		expect(result).toMatchSnapshot();
 	});
 
 	test('without diagnostics, should return generic completions', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['bar'];
 		mockContext.__options.snippet = ['bar'];
 		mockContext.getModule.mockReturnValue({
 			getDiagnostics: () => [],
+			dispose: () => undefined,
 		});
 
 		const module = new CompletionModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -197,10 +204,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'foo' },
-			position: Position.create(0, 0),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'foo' },
+				position: Position.create(0, 0),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(mockContext.getModule).toHaveBeenCalledTimes(1);
 		expect(result).toMatchSnapshot();
@@ -223,6 +234,7 @@ describe('CompletionModule', () => {
 					range: Range.create(1, 2, 1, 4),
 				},
 			],
+			dispose: () => undefined,
 		});
 
 		const module = new CompletionModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -231,10 +243,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'file:///path/test.css' },
-			position: Position.create(1, 3),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'file:///path/test.css' },
+				position: Position.create(1, 3),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(mockContext.getModule).toHaveBeenCalledTimes(1);
 		expect(result).toMatchSnapshot();
@@ -252,6 +268,7 @@ describe('CompletionModule', () => {
 					range: Range.create(1, 2, 3, 4),
 				},
 			],
+			dispose: () => undefined,
 		});
 
 		const module = new CompletionModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -260,10 +277,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'file:///path/test.css' },
-			position: Position.create(1, 3),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'file:///path/test.css' },
+				position: Position.create(1, 3),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(mockContext.getModule).toHaveBeenCalledTimes(1);
 		expect(result).toMatchSnapshot();
@@ -283,6 +304,7 @@ describe('CompletionModule', () => {
 					range: Range.create(2, 1, 3, 4),
 				},
 			],
+			dispose: () => undefined,
 		});
 
 		const module = new CompletionModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -291,10 +313,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'file:///path/test.css' },
-			position: Position.create(1, 3),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'file:///path/test.css' },
+				position: Position.create(1, 3),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(mockContext.getModule).toHaveBeenCalledTimes(1);
 		expect(result).toMatchSnapshot();
@@ -316,6 +342,7 @@ describe('CompletionModule', () => {
 					range: Range.create(1, 2, 3, 4),
 				}),
 			],
+			dispose: () => undefined,
 		});
 
 		const module = new CompletionModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -324,10 +351,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'file:///path/test.css' },
-			position: Position.create(1, 3),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'file:///path/test.css' },
+				position: Position.create(1, 3),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(mockContext.getModule).toHaveBeenCalledTimes(1);
 		expect(result).toMatchSnapshot();
@@ -350,6 +381,7 @@ describe('CompletionModule', () => {
 					range: Range.create(1, 2, 3, 4),
 				},
 			],
+			dispose: () => undefined,
 		});
 
 		const module = new CompletionModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -358,10 +390,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'file:///path/test.css' },
-			position: Position.create(1, 28),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'file:///path/test.css' },
+				position: Position.create(1, 28),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(mockContext.getModule).toHaveBeenCalledTimes(1);
 		expect(result).toMatchSnapshot();
@@ -384,6 +420,7 @@ describe('CompletionModule', () => {
 					range: Range.create(2, 2, 3, 4),
 				},
 			],
+			dispose: () => undefined,
 		});
 
 		const module = new CompletionModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -392,10 +429,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'file:///path/test.css' },
-			position: Position.create(1, 33),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'file:///path/test.css' },
+				position: Position.create(1, 33),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(mockContext.getModule).toHaveBeenCalledTimes(1);
 		expect(result).toMatchSnapshot();
@@ -418,6 +459,7 @@ describe('CompletionModule', () => {
 					range: Range.create(2, 2, 3, 4),
 				},
 			],
+			dispose: () => undefined,
 		});
 
 		const module = new CompletionModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -426,10 +468,14 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'file:///path/test.css' },
-			position: Position.create(1, 23),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'file:///path/test.css' },
+				position: Position.create(1, 23),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(mockContext.getModule).toHaveBeenCalledTimes(1);
 		expect(result).toMatchSnapshot();
@@ -453,6 +499,7 @@ describe('CompletionModule', () => {
 					range: Range.create(3, 2, 3, 4),
 				},
 			],
+			dispose: () => undefined,
 		});
 
 		const module = new CompletionModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -461,12 +508,44 @@ describe('CompletionModule', () => {
 
 		const handler = mockContext.connection.onCompletion.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'file:///path/test.css' },
-			position: Position.create(1, 25),
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'file:///path/test.css' },
+				position: Position.create(1, 25),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(mockContext.getModule).toHaveBeenCalledTimes(1);
 		expect(result).toMatchSnapshot();
 	});
+
+	it('should be disposable', () => {
+		const module = new CompletionModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		expect(module).toHaveProperty('dispose');
+		expect(module.dispose).toBeInstanceOf(Function);
+	});
+
+	it('should set a no-op completion handler when disposed', async () => {
+		const module = new CompletionModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		module.onDidRegisterHandlers();
+		module.dispose();
+
+		const handler = mockContext.connection.onCompletion.mock.calls[1][0];
+
+		const result = await handler(
+			{
+				textDocument: { uri: 'foo' },
+				position: Position.create(0, 0),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		expect(mockContext.connection.onCompletion).toHaveBeenCalledTimes(2);
+		expect(result).toBeUndefined();
+	});
 });
diff --git a/src/server/modules/__tests__/formatter.ts b/src/server/modules/__tests__/formatter.ts
index c7bc287..f931a7d 100644
--- a/src/server/modules/__tests__/formatter.ts
+++ b/src/server/modules/__tests__/formatter.ts
@@ -20,7 +20,9 @@ jest.mock('vscode-uri', () => {
 	return mock;
 });
 
+import { WorkDoneProgressReporter } from 'vscode-languageserver';
 import * as LSP from 'vscode-languageserver-protocol';
+import { TextDocument } from 'vscode-languageserver-textdocument';
 import { Position, TextEdit } from 'vscode-languageserver-types';
 
 import { Notification } from '../../types';
@@ -79,12 +81,9 @@ describe('FormatterModule', () => {
 	});
 
 	test('should format documents', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['bar'];
-		mockContext.getFixes.mockReturnValue([TextEdit.insert(Position.create(0, 0), 'text')]);
+		mockContext.getFixes.mockResolvedValue([TextEdit.insert(Position.create(0, 0), 'text')]);
 
 		const module = new FormatterModule({ context: mockContext.__typed(), logger: mockLogger });
 
@@ -92,32 +91,23 @@ describe('FormatterModule', () => {
 
 		const handler = mockContext.connection.onDocumentFormatting.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'foo' },
-			options: {
-				insertSpaces: true,
-				tabSize: 2,
-				insertFinalNewline: true,
-				trimFinalNewlines: false,
+		const result = await handler(
+			{
+				textDocument: { uri: 'foo' },
+				options: {
+					insertSpaces: true,
+					tabSize: 2,
+					insertFinalNewline: true,
+					trimFinalNewlines: false,
+				},
 			},
-		});
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toMatchSnapshot();
 		expect(mockContext.getFixes.mock.calls[0]).toMatchSnapshot();
-		expect(mockContext.getFixes.mock.results[0].value).toStrictEqual(result);
-	});
-
-	test('with incorrect command, should not attempt to format', async () => {
-		const module = new FormatterModule({ context: mockContext.__typed() });
-
-		module.onDidRegisterHandlers();
-
-		const handler = mockContext.connection.onDocumentFormatting.mock.calls[0][0];
-
-		const result = await handler({ command: 'foo' });
-
-		expect(result).toBeNull();
-		expect(mockContext.getFixes).not.toHaveBeenCalled();
+		expect(await mockContext.getFixes.mock.results[0].value).toStrictEqual(result);
 	});
 
 	test('with no text document, should not attempt to format', async () => {
@@ -127,7 +117,14 @@ describe('FormatterModule', () => {
 
 		const handler = mockContext.connection.onDocumentFormatting.mock.calls[0][0];
 
-		const result = await handler({ options: {} });
+		const result = await handler(
+			{
+				options: { insertSpaces: true, tabSize: 2 },
+				textDocument: undefined as unknown as LSP.TextDocumentIdentifier,
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toBeNull();
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -143,10 +140,14 @@ describe('FormatterModule', () => {
 
 		const handler = mockContext.connection.onDocumentFormatting.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'foo' },
-			options: {},
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'foo' },
+				options: { insertSpaces: true, tabSize: 2 },
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toBeNull();
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -154,10 +155,7 @@ describe('FormatterModule', () => {
 	});
 
 	test('if document language ID is not in options, should not attempt to format', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['baz'];
 
 		const module = new FormatterModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -166,10 +164,14 @@ describe('FormatterModule', () => {
 
 		const handler = mockContext.connection.onDocumentFormatting.mock.calls[0][0];
 
-		const result = await handler({
-			textDocument: { uri: 'foo' },
-			options: {},
-		});
+		const result = await handler(
+			{
+				textDocument: { uri: 'foo' },
+				options: { insertSpaces: true, tabSize: 2 },
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toBeNull();
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -183,10 +185,7 @@ describe('FormatterModule', () => {
 	});
 
 	test('with no debug log level and no valid document, should not attempt to log reason', async () => {
-		mockContext.documents.get.mockReturnValue({
-			uri: 'foo',
-			languageId: 'bar',
-		});
+		mockContext.documents.get.mockReturnValue(TextDocument.create('foo', 'bar', 1, 'a {}'));
 		mockContext.__options.validate = ['baz'];
 		mockLogger.isDebugEnabled.mockReturnValue(false);
 
@@ -196,12 +195,16 @@ describe('FormatterModule', () => {
 
 		const handler = mockContext.connection.onDocumentFormatting.mock.calls[0][0];
 
-		const handlerParams = {
+		const handlerParams: LSP.DocumentFormattingParams = {
 			textDocument: { uri: 'foo' },
-			options: {},
+			options: { insertSpaces: true, tabSize: 2 },
 		};
 
-		const result = await handler(handlerParams);
+		const result = await handler(
+			handlerParams,
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
 
 		expect(result).toBeNull();
 		expect(mockContext.getFixes).not.toHaveBeenCalled();
@@ -228,7 +231,7 @@ describe('FormatterModule', () => {
 
 		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
-		await onDidOpenHandler({ document: { uri: 'foo', languageId: 'bar' } });
+		await onDidOpenHandler({ document: TextDocument.create('foo', 'bar', 1, 'a {}') });
 
 		expect(mockContext.connection.client.register).not.toHaveBeenCalled();
 	});
@@ -250,7 +253,7 @@ describe('FormatterModule', () => {
 
 		const onDidChangeContentHandler = mockContext.documents.onDidChangeContent.mock.calls[0][0];
 
-		await onDidChangeContentHandler({ document: { uri: 'foo', languageId: 'bar' } });
+		await onDidChangeContentHandler({ document: TextDocument.create('foo', 'bar', 1, 'a {}') });
 
 		expect(mockContext.connection.client.register).not.toHaveBeenCalled();
 	});
@@ -272,7 +275,7 @@ describe('FormatterModule', () => {
 
 		const onDidSaveHandler = mockContext.documents.onDidSave.mock.calls[0][0];
 
-		await onDidSaveHandler({ document: { uri: 'foo', languageId: 'bar' } });
+		await onDidSaveHandler({ document: TextDocument.create('foo', 'bar', 1, 'a {}') });
 
 		expect(mockContext.connection.client.register).not.toHaveBeenCalled();
 	});
@@ -294,8 +297,12 @@ describe('FormatterModule', () => {
 
 		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test.css', languageId: 'bar' } });
-		await onDidOpenHandler({ document: { uri: 'scheme:///dir/test.css', languageId: 'bar' } });
+		await onDidOpenHandler({
+			document: TextDocument.create('file:///dir/test.css', 'bar', 1, 'a {}'),
+		});
+		await onDidOpenHandler({
+			document: TextDocument.create('scheme:///dir/test.css', 'bar', 1, 'a {}'),
+		});
 
 		expect(mockContext.connection.client.register).toHaveBeenCalledWith(
 			LSP.DocumentFormattingRequest.type,
@@ -325,7 +332,7 @@ describe('FormatterModule', () => {
 		const onDidChangeContentHandler = mockContext.documents.onDidChangeContent.mock.calls[0][0];
 
 		await onDidChangeContentHandler({
-			document: { uri: 'file:///dir/test.css', languageId: 'bar' },
+			document: TextDocument.create('file:///dir/test.css', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.connection.client.register).toHaveBeenCalledWith(
@@ -351,7 +358,9 @@ describe('FormatterModule', () => {
 
 		const onDidSaveHandler = mockContext.documents.onDidSave.mock.calls[0][0];
 
-		await onDidSaveHandler({ document: { uri: 'file:///dir/test.css', languageId: 'bar' } });
+		await onDidSaveHandler({
+			document: TextDocument.create('file:///dir/test.css', 'bar', 1, 'a {}'),
+		});
 
 		expect(mockContext.connection.client.register).toHaveBeenCalledWith(
 			LSP.DocumentFormattingRequest.type,
@@ -379,11 +388,13 @@ describe('FormatterModule', () => {
 
 		module.onDidRegisterHandlers();
 
+		const document = TextDocument.create('file:///dir/test.css', 'bar', 1, 'a {}');
+
 		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
 		const onDidCloseHandler = mockContext.documents.onDidClose.mock.calls[0][0];
 
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test.css', languageId: 'bar' } });
-		await onDidCloseHandler({ document: { uri: 'file:///dir/test.css' } });
+		await onDidOpenHandler({ document });
+		await onDidCloseHandler({ document });
 
 		expect(mockRegistration.dispose).toHaveBeenCalled();
 		expect(mockLogger.debug).toHaveBeenCalledWith('Deregistering formatter for document', {
@@ -411,11 +422,13 @@ describe('FormatterModule', () => {
 
 		module.onDidRegisterHandlers();
 
+		const document = TextDocument.create('file:///dir/test.css', 'bar', 1, 'a {}');
+
 		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
 		const onDidCloseHandler = mockContext.documents.onDidClose.mock.calls[0][0];
 
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test.css', languageId: 'bar' } });
-		await onDidCloseHandler({ document: { uri: 'file:///dir/test.css' } });
+		await onDidOpenHandler({ document });
+		await onDidCloseHandler({ document });
 
 		await new Promise((resolve) => setImmediate(resolve));
 
@@ -443,7 +456,9 @@ describe('FormatterModule', () => {
 
 		const onDidCloseHandler = mockContext.documents.onDidClose.mock.calls[0][0];
 
-		await onDidCloseHandler({ document: { uri: 'file:///dir/test.css' } });
+		await onDidCloseHandler({
+			document: TextDocument.create('file:///dir/test.css', 'bar', 1, 'a {}'),
+		});
 
 		expect(mockLogger.debug).not.toHaveBeenCalledWith('Deregistering formatter for document', {
 			uri: 'file:///dir/test.css',
@@ -472,14 +487,18 @@ describe('FormatterModule', () => {
 
 		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test-1.css', languageId: 'bar' } });
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test-2.css', languageId: 'bar' } });
+		await onDidOpenHandler({
+			document: TextDocument.create('file:///dir/test-1.css', 'bar', 1, 'a {}'),
+		});
+		await onDidOpenHandler({
+			document: TextDocument.create('file:///dir/test-2.css', 'bar', 1, 'a {}'),
+		});
 
 		const didChangeConfigurationNotificationHandler = mockContext.notifications.on.mock.calls.find(
 			([type]) => type === LSP.DidChangeConfigurationNotification.type,
-		)[1];
+		)?.[1];
 
-		await didChangeConfigurationNotificationHandler();
+		await didChangeConfigurationNotificationHandler?.();
 
 		expect(mockRegistration.dispose).toHaveBeenCalledTimes(2);
 	});
@@ -506,14 +525,18 @@ describe('FormatterModule', () => {
 
 		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test-1.css', languageId: 'bar' } });
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test-2.css', languageId: 'bar' } });
+		await onDidOpenHandler({
+			document: TextDocument.create('file:///dir/test-1.css', 'bar', 1, 'a {}'),
+		});
+		await onDidOpenHandler({
+			document: TextDocument.create('file:///dir/test-2.css', 'bar', 1, 'a {}'),
+		});
 
 		const didChangeConfigurationNotificationHandler = mockContext.notifications.on.mock.calls.find(
 			([type]) => type === LSP.DidChangeConfigurationNotification.type,
-		)[1];
+		)?.[1];
 
-		await didChangeConfigurationNotificationHandler();
+		await didChangeConfigurationNotificationHandler?.();
 
 		await new Promise((resolve) => setImmediate(resolve));
 
@@ -549,15 +572,19 @@ describe('FormatterModule', () => {
 
 		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test-1.css', languageId: 'bar' } });
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test-2.css', languageId: 'bar' } });
+		await onDidOpenHandler({
+			document: TextDocument.create('file:///dir/test-1.css', 'bar', 1, 'a {}'),
+		});
+		await onDidOpenHandler({
+			document: TextDocument.create('file:///dir/test-2.css', 'bar', 1, 'a {}'),
+		});
 
 		const didChangeWorkspaceFoldersNotificationHandler =
 			mockContext.notifications.on.mock.calls.find(
 				([type]) => type === LSP.DidChangeWorkspaceFoldersNotification.type,
-			)[1];
+			)?.[1];
 
-		await didChangeWorkspaceFoldersNotificationHandler();
+		await didChangeWorkspaceFoldersNotificationHandler?.();
 
 		expect(mockRegistration.dispose).toHaveBeenCalledTimes(2);
 	});
@@ -584,15 +611,19 @@ describe('FormatterModule', () => {
 
 		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test-1.css', languageId: 'bar' } });
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test-2.css', languageId: 'bar' } });
+		await onDidOpenHandler({
+			document: TextDocument.create('file:///dir/test-1.css', 'bar', 1, 'a {}'),
+		});
+		await onDidOpenHandler({
+			document: TextDocument.create('file:///dir/test-2.css', 'bar', 1, 'a {}'),
+		});
 
 		const didChangeWorkspaceFoldersNotificationHandler =
 			mockContext.notifications.on.mock.calls.find(
 				([type]) => type === LSP.DidChangeWorkspaceFoldersNotification.type,
-			)[1];
+			)?.[1];
 
-		await didChangeWorkspaceFoldersNotificationHandler();
+		await didChangeWorkspaceFoldersNotificationHandler?.();
 
 		await new Promise((resolve) => setImmediate(resolve));
 
@@ -621,11 +652,13 @@ describe('FormatterModule', () => {
 
 		module.onDidRegisterHandlers();
 
+		const document = TextDocument.create('file:///dir/test.css', 'bar', 1, 'a {}');
+
 		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
 		const onDidSaveHandler = mockContext.documents.onDidSave.mock.calls[0][0];
 
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test.css', languageId: 'bar' } });
-		await onDidSaveHandler({ document: { uri: 'file:///dir/test.css', languageId: 'bar' } });
+		await onDidOpenHandler({ document });
+		await onDidSaveHandler({ document });
 
 		expect(mockContext.connection.client.register).toHaveBeenCalledTimes(1);
 	});
@@ -647,7 +680,9 @@ describe('FormatterModule', () => {
 
 		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
-		await onDidOpenHandler({ document: { uri: 'file:///dir/test.css', languageId: 'bar' } });
+		await onDidOpenHandler({
+			document: TextDocument.create('file:///dir/test.css', 'bar', 1, 'a {}'),
+		});
 
 		expect(mockContext.connection.sendNotification).toHaveBeenCalledWith(
 			Notification.DidRegisterDocumentFormattingEditProvider,
@@ -659,4 +694,57 @@ describe('FormatterModule', () => {
 			},
 		);
 	});
+
+	it('should be disposable', () => {
+		const module = new FormatterModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		expect(module).toHaveProperty('dispose');
+		expect(module.dispose).toBeInstanceOf(Function);
+	});
+
+	it('should set a no-op formatting handler when disposed', async () => {
+		const module = new FormatterModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		module.onDidRegisterHandlers();
+		module.dispose();
+
+		const handler = mockContext.connection.onDocumentFormatting.mock.calls[1][0];
+
+		const result = await handler(
+			{
+				textDocument: { uri: 'file:///dir/test.css' },
+				options: {
+					tabSize: 4,
+					insertSpaces: true,
+				},
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		expect(mockContext.connection.onDocumentFormatting).toHaveBeenCalledTimes(2);
+		expect(result).toBeUndefined();
+	});
+
+	it('should dispose all handler registrations when disposed', () => {
+		const module = new FormatterModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		module.onDidRegisterHandlers();
+		module.dispose();
+
+		const disposables = [
+			...mockContext.notifications.on.mock.results,
+			...mockContext.commands.on.mock.results,
+			...mockContext.documents.onDidOpen.mock.results,
+			...mockContext.documents.onDidSave.mock.results,
+			...mockContext.documents.onDidClose.mock.results,
+			...mockContext.documents.onDidChangeContent.mock.results,
+		];
+
+		expect(disposables).toHaveLength(6);
+
+		for (const disposable of disposables) {
+			expect(disposable.value.dispose).toHaveBeenCalledTimes(1);
+		}
+	});
 });
diff --git a/src/server/modules/__tests__/old-stylelint-warning.ts b/src/server/modules/__tests__/old-stylelint-warning.ts
index 4681e42..c916f2f 100644
--- a/src/server/modules/__tests__/old-stylelint-warning.ts
+++ b/src/server/modules/__tests__/old-stylelint-warning.ts
@@ -6,9 +6,11 @@ jest.mock('path');
 import fs from 'fs/promises';
 import path from 'path';
 import type LSP from 'vscode-languageserver-protocol';
+import { TextDocument } from 'vscode-languageserver-textdocument';
 import { getWorkspaceFolder } from '../../../utils/documents';
 import { findPackageRoot } from '../../../utils/packages';
 import { OldStylelintWarningModule } from '../old-stylelint-warning';
+import { Stylelint } from '../../../utils/stylelint';
 
 const mockedFS = fs as tests.mocks.FSPromisesModule;
 const mockedPath = path as tests.mocks.PathModule;
@@ -17,14 +19,16 @@ const mockedGetWorkspaceFolder = getWorkspaceFolder as jest.MockedFunction<
 >;
 const mockedFindPackageRoot = findPackageRoot as jest.MockedFunction<typeof findPackageRoot>;
 
-const mockContext = serverMocks.getContext();
-const mockLogger = serverMocks.getLogger();
+let mockContext: ReturnType<typeof serverMocks.getContext>;
+let mockLogger: ReturnType<typeof serverMocks.getLogger>;
 
 describe('OldStylelintWarningModule', () => {
 	beforeEach(() => {
+		jest.clearAllMocks();
 		mockedPath.__mockPlatform('posix');
+		mockContext = serverMocks.getContext();
+		mockLogger = serverMocks.getLogger();
 		mockContext.__options.validate = [];
-		jest.clearAllMocks();
 	});
 
 	test('should be constructable', () => {
@@ -40,8 +44,9 @@ describe('OldStylelintWarningModule', () => {
 		expect(mockContext.documents.onDidOpen).toHaveBeenCalledWith(expect.any(Function));
 	});
 
-	test('if document language ID is not in options, should not warn', async () => {
-		mockContext.__options.validate = ['baz'];
+	test('if document is not part of a workspace, should not warn', async () => {
+		mockedGetWorkspaceFolder.mockResolvedValue(undefined);
+		mockContext.__options.validate = ['bar'];
 
 		const module = new OldStylelintWarningModule({
 			context: mockContext.__typed(),
@@ -55,19 +60,19 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).not.toHaveBeenCalled();
 		expect(mockContext.connection.window.showWarningMessage).not.toHaveBeenCalled();
 		expect(mockLogger.debug).toHaveBeenLastCalledWith(
-			'Document should not be validated, ignoring',
-			{ uri: 'foo', language: 'bar' },
+			'Document not part of a workspace, ignoring',
+			{ uri: 'foo' },
 		);
 	});
 
-	test('if document is not part of a workspace, should not warn', async () => {
-		mockedGetWorkspaceFolder.mockResolvedValue(undefined);
+	test('if document has already been checked, should not warn', async () => {
+		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockContext.__options.validate = ['bar'];
 
 		const module = new OldStylelintWarningModule({
@@ -79,22 +84,27 @@ describe('OldStylelintWarningModule', () => {
 
 		module.onDidRegisterHandlers();
 
+		const document = TextDocument.create('foo', 'bar', 1, 'a {}');
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
-		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
-		});
+		await handler({ document });
+		await handler({ document });
 
-		expect(mockContext.resolveStylelint).not.toHaveBeenCalled();
+		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
 		expect(mockContext.connection.window.showWarningMessage).not.toHaveBeenCalled();
 		expect(mockLogger.debug).toHaveBeenLastCalledWith(
-			'Document not part of a workspace, ignoring',
+			'Document has already been checked, ignoring',
 			{ uri: 'foo' },
 		);
 	});
 
-	test('if document has already been checked, should not warn', async () => {
+	test('if Stylelint package root cannot be determined, should not warn', async () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
+		mockedFindPackageRoot.mockResolvedValue(undefined);
+		mockContext.resolveStylelint.mockResolvedValue({
+			stylelint: {} as unknown as Stylelint,
+			resolvedPath: '/path/node_modules/stylelint',
+		});
 		mockContext.__options.validate = ['bar'];
 
 		const module = new OldStylelintWarningModule({
@@ -109,29 +119,27 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
-		});
-
-		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
 		expect(mockContext.connection.window.showWarningMessage).not.toHaveBeenCalled();
-		expect(mockLogger.debug).toHaveBeenLastCalledWith(
-			'Document has already been checked, ignoring',
-			{ uri: 'foo' },
-		);
+		expect(mockLogger.debug).toHaveBeenLastCalledWith('Stylelint package root not found', {
+			uri: 'foo',
+		});
 	});
 
-	test('if Stylelint package root cannot be determined, should not warn', async () => {
+	test('if Stylelint package root is not in workspace, should not warn', async () => {
+		const error = new Error('foo');
+
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
-		mockedFindPackageRoot.mockResolvedValue(undefined);
+		mockedFindPackageRoot.mockResolvedValue('/usr/local/lib/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as Stylelint,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
+		mockedFS.readFile.mockRejectedValue(error);
 
 		const module = new OldStylelintWarningModule({
 			context: mockContext.__typed(),
@@ -145,14 +153,15 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
 		expect(mockContext.connection.window.showWarningMessage).not.toHaveBeenCalled();
-		expect(mockLogger.debug).toHaveBeenLastCalledWith('Stylelint package root not found', {
-			uri: 'foo',
-		});
+		expect(mockLogger.debug).toHaveBeenLastCalledWith(
+			'Stylelint package root is not inside the workspace',
+			{ uri: 'foo' },
+		);
 	});
 
 	test('if Stylelint package manifest cannot be read, should not warn', async () => {
@@ -161,7 +170,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as Stylelint,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -179,7 +188,7 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
@@ -194,7 +203,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as Stylelint,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -212,7 +221,7 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
@@ -231,7 +240,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as Stylelint,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -249,7 +258,7 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
@@ -260,7 +269,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as Stylelint,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -278,7 +287,7 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
@@ -294,7 +303,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as Stylelint,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -312,7 +321,7 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
@@ -323,7 +332,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as Stylelint,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -341,7 +350,7 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
@@ -352,7 +361,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as Stylelint,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -370,7 +379,7 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
@@ -383,7 +392,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as Stylelint,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -408,7 +417,7 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
@@ -420,7 +429,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as Stylelint,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -450,7 +459,7 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
@@ -464,7 +473,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as Stylelint,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -494,7 +503,7 @@ describe('OldStylelintWarningModule', () => {
 		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
@@ -502,4 +511,57 @@ describe('OldStylelintWarningModule', () => {
 		expect(mockContext.connection.window.showDocument).toHaveBeenCalledTimes(1);
 		expect(mockLogger.warn).toHaveBeenCalledWith('Failed to open migration guide');
 	});
+
+	test('if document language ID is not in options, should warn', async () => {
+		mockedGetWorkspaceFolder.mockResolvedValue('/path');
+		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
+		mockContext.resolveStylelint.mockResolvedValue({
+			stylelint: {} as unknown as Stylelint,
+			resolvedPath: '/path/node_modules/stylelint',
+		});
+		mockContext.__options.validate = ['baz'];
+
+		const module = new OldStylelintWarningModule({
+			context: mockContext.__typed(),
+			logger: mockLogger,
+		});
+
+		module.onInitialize({ capabilities: {} } as unknown as LSP.InitializeParams);
+
+		module.onDidRegisterHandlers();
+
+		const handler = mockContext.documents.onDidOpen.mock.calls[0][0];
+
+		await handler({
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
+		});
+
+		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
+		expect(mockContext.connection.window.showWarningMessage).toHaveBeenCalledTimes(1);
+		expect(mockContext.connection.window.showDocument).not.toHaveBeenCalled();
+	});
+
+	it('should be disposable', () => {
+		const module = new OldStylelintWarningModule({
+			context: mockContext.__typed(),
+			logger: mockLogger,
+		});
+
+		expect(module).toHaveProperty('dispose');
+		expect(module.dispose).toBeInstanceOf(Function);
+	});
+
+	it('should dispose all handler registrations when disposed', () => {
+		const module = new OldStylelintWarningModule({
+			context: mockContext.__typed(),
+			logger: mockLogger,
+		});
+
+		module.onDidRegisterHandlers();
+		module.dispose();
+
+		const disposable = mockContext.documents.onDidOpen.mock.results[0].value;
+
+		expect(disposable.dispose).toHaveBeenCalledTimes(1);
+	});
 });
diff --git a/src/server/modules/__tests__/validator.ts b/src/server/modules/__tests__/validator.ts
index 8ea84fb..73c310a 100644
--- a/src/server/modules/__tests__/validator.ts
+++ b/src/server/modules/__tests__/validator.ts
@@ -1,4 +1,5 @@
 import { DidChangeWatchedFilesNotification } from 'vscode-languageserver-protocol';
+import { TextDocument } from 'vscode-languageserver-textdocument';
 import { Range } from 'vscode-languageserver-types';
 import { ValidatorModule } from '../validator';
 
@@ -41,7 +42,7 @@ describe('ValidatorModule', () => {
 		const handler = mockContext.documents.onDidChangeContent.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.lintDocument).not.toHaveBeenCalled();
@@ -61,7 +62,7 @@ describe('ValidatorModule', () => {
 		const handler = mockContext.documents.onDidChangeContent.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.lintDocument).toHaveBeenCalledTimes(1);
@@ -87,7 +88,7 @@ describe('ValidatorModule', () => {
 		const handler = mockContext.documents.onDidChangeContent.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.lintDocument).toHaveBeenCalledTimes(1);
@@ -129,7 +130,7 @@ describe('ValidatorModule', () => {
 		const handler = mockContext.documents.onDidChangeContent.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		expect(mockContext.lintDocument).toHaveBeenCalledTimes(1);
@@ -154,7 +155,7 @@ describe('ValidatorModule', () => {
 
 	test('onInitialize should validate all documents', async () => {
 		mockContext.__options.validate = ['baz'];
-		mockContext.lintDocument.mockImplementation((document) => {
+		mockContext.lintDocument.mockImplementation(async (document) => {
 			return document.uri === 'foo'
 				? {
 						diagnostics: [
@@ -176,8 +177,8 @@ describe('ValidatorModule', () => {
 				  };
 		});
 		mockContext.documents.all.mockReturnValueOnce([
-			{ uri: 'foo', languageId: 'baz' },
-			{ uri: 'bar', languageId: 'baz' },
+			TextDocument.create('foo', 'baz', 1, 'a {}'),
+			TextDocument.create('bar', 'baz', 1, 'a {}'),
 		]);
 
 		const module = new ValidatorModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -215,7 +216,7 @@ describe('ValidatorModule', () => {
 
 	test('onDidChangeWatchedFiles should validate all documents', async () => {
 		mockContext.__options.validate = ['baz'];
-		mockContext.lintDocument.mockImplementation((document) => {
+		mockContext.lintDocument.mockImplementation(async (document) => {
 			return document.uri === 'foo'
 				? {
 						diagnostics: [
@@ -237,8 +238,8 @@ describe('ValidatorModule', () => {
 				  };
 		});
 		mockContext.documents.all.mockReturnValueOnce([
-			{ uri: 'foo', languageId: 'baz' },
-			{ uri: 'bar', languageId: 'baz' },
+			TextDocument.create('foo', 'baz', 1, 'a {}'),
+			TextDocument.create('bar', 'baz', 1, 'a {}'),
 		]);
 
 		const module = new ValidatorModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -247,9 +248,9 @@ describe('ValidatorModule', () => {
 
 		const handler = mockContext.notifications.on.mock.calls.find(
 			([type]) => type === DidChangeWatchedFilesNotification.type,
-		)[1];
+		)?.[1];
 
-		await handler();
+		await handler?.();
 
 		expect(mockContext.lintDocument).toHaveBeenCalledTimes(2);
 		expect(mockContext.connection.sendDiagnostics).toHaveBeenCalledTimes(2);
@@ -279,7 +280,7 @@ describe('ValidatorModule', () => {
 
 	test('onDidChangeConfiguration should validate all documents', async () => {
 		mockContext.__options.validate = ['baz'];
-		mockContext.lintDocument.mockImplementation((document) => {
+		mockContext.lintDocument.mockImplementation(async (document) => {
 			return document.uri === 'foo'
 				? {
 						diagnostics: [
@@ -301,8 +302,8 @@ describe('ValidatorModule', () => {
 				  };
 		});
 		mockContext.documents.all.mockReturnValueOnce([
-			{ uri: 'foo', languageId: 'baz' },
-			{ uri: 'bar', languageId: 'baz' },
+			TextDocument.create('foo', 'baz', 1, 'a {}'),
+			TextDocument.create('bar', 'baz', 1, 'a {}'),
 		]);
 
 		const module = new ValidatorModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -354,7 +355,7 @@ describe('ValidatorModule', () => {
 		const handler = mockContext.documents.onDidChangeContent.mock.calls[0][0];
 
 		await handler({
-			document: { uri: 'foo', languageId: 'bar' },
+			document: TextDocument.create('foo', 'bar', 1, 'a {}'),
 		});
 
 		const cached = module.getDiagnostics('foo');
@@ -385,14 +386,13 @@ describe('ValidatorModule', () => {
 
 		module.onDidRegisterHandlers();
 
+		const document = TextDocument.create('foo', 'bar', 1, 'a {}');
+
 		const onDidChangeContentHandler = mockContext.documents.onDidChangeContent.mock.calls[0][0];
 		const onDidCloseHandler = mockContext.documents.onDidClose.mock.calls[0][0];
 
-		await onDidChangeContentHandler({
-			document: { uri: 'foo', languageId: 'bar' },
-		});
-
-		onDidCloseHandler({ document: { uri: 'foo', languageId: 'bar' } });
+		await onDidChangeContentHandler({ document });
+		onDidCloseHandler({ document });
 
 		const cached = module.getDiagnostics('foo');
 
@@ -412,7 +412,7 @@ describe('ValidatorModule', () => {
 
 	test('when the configuration is updated, all documents should be revalidated', async () => {
 		mockContext.__options.validate = ['baz', 'qux'];
-		mockContext.lintDocument.mockImplementation((document) => {
+		mockContext.lintDocument.mockImplementation(async (document) => {
 			return document.uri === 'foo'
 				? {
 						diagnostics: [
@@ -434,8 +434,8 @@ describe('ValidatorModule', () => {
 				  };
 		});
 		mockContext.documents.all.mockReturnValue([
-			{ uri: 'foo', languageId: 'baz' },
-			{ uri: 'bar', languageId: 'qux' },
+			TextDocument.create('foo', 'baz', 1, 'a {}'),
+			TextDocument.create('bar', 'qux', 1, 'a {}'),
 		]);
 
 		const module = new ValidatorModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -469,7 +469,7 @@ describe('ValidatorModule', () => {
 
 	test('when the configuration is updated, documents excluded from new config should have diagnostics cleared', async () => {
 		mockContext.__options.validate = ['baz', 'qux'];
-		mockContext.lintDocument.mockImplementation((document) => {
+		mockContext.lintDocument.mockImplementation(async (document) => {
 			return document.uri === 'foo'
 				? {
 						diagnostics: [
@@ -491,8 +491,8 @@ describe('ValidatorModule', () => {
 				  };
 		});
 		mockContext.documents.all.mockReturnValue([
-			{ uri: 'foo', languageId: 'baz' },
-			{ uri: 'bar', languageId: 'qux' },
+			TextDocument.create('foo', 'baz', 1, 'a {}'),
+			TextDocument.create('bar', 'qux', 1, 'a {}'),
 		]);
 
 		const module = new ValidatorModule({ context: mockContext.__typed(), logger: mockLogger });
@@ -537,4 +537,30 @@ describe('ValidatorModule', () => {
 			},
 		);
 	});
+
+	it('should be disposable', () => {
+		const module = new ValidatorModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		expect(module).toHaveProperty('dispose');
+		expect(module.dispose).toBeInstanceOf(Function);
+	});
+
+	it('should dispose all handler registrations when disposed', () => {
+		const module = new ValidatorModule({ context: mockContext.__typed(), logger: mockLogger });
+
+		module.onDidRegisterHandlers();
+		module.dispose();
+
+		const disposables = [
+			...mockContext.notifications.on.mock.results,
+			...mockContext.documents.onDidClose.mock.results,
+			...mockContext.documents.onDidChangeContent.mock.results,
+		];
+
+		expect(disposables).toHaveLength(3);
+
+		for (const disposable of disposables) {
+			expect(disposable.value.dispose).toHaveBeenCalledTimes(1);
+		}
+	});
 });
diff --git a/src/utils/logging/__tests__/language-server-transport.ts b/src/utils/logging/__tests__/language-server-transport.ts
index fa0eae7..6943bc2 100644
--- a/src/utils/logging/__tests__/language-server-transport.ts
+++ b/src/utils/logging/__tests__/language-server-transport.ts
@@ -1,19 +1,10 @@
 import { LEVEL, MESSAGE } from 'triple-beam';
 import type winston from 'winston';
-import type { Connection } from 'vscode-languageserver';
 import { LanguageServerTransport } from '../language-server-transport';
 
 // Test winston transport
 
-const mockConnection = {
-	console: {
-		connection: {},
-		error: jest.fn(),
-		warn: jest.fn(),
-		info: jest.fn(),
-		log: jest.fn(),
-	},
-} as unknown as Connection;
+const mockConnection = serverMocks.getConnection();
 
 const createMockInfo = (message: string, level: string) =>
 	({
@@ -90,4 +81,36 @@ describe('LanguageServerTransport', () => {
 
 		expect(mockConnection.console.log).toHaveBeenCalledWith('test');
 	});
+
+	test('should not emit disposed connection errors', () => {
+		const info = createMockInfo('test', 'info');
+		const onError = jest.fn();
+
+		mockConnection.console.info.mockImplementationOnce(() => {
+			throw new Error('Connection is disposed.');
+		});
+
+		transport.on('error', onError);
+		transport.log(info, () => undefined);
+
+		expect(mockConnection.console.info).toHaveBeenCalledTimes(1);
+		expect(onError).toHaveBeenCalledTimes(0);
+	});
+
+	test('should emit errors thrown when attempting to log', () => {
+		const info = createMockInfo('test', 'info');
+		const onError = jest.fn();
+		const error = new Error('test');
+
+		mockConnection.console.info.mockImplementationOnce(() => {
+			throw error;
+		});
+
+		transport.on('error', onError);
+		transport.log(info, () => undefined);
+
+		expect(mockConnection.console.info).toHaveBeenCalledTimes(1);
+		expect(onError).toHaveBeenCalledTimes(1);
+		expect(onError).toHaveBeenCalledWith(error);
+	});
 });
diff --git a/src/utils/lsp/__tests__/command-manager.ts b/src/utils/lsp/__tests__/command-manager.ts
index fa5b55a..54e2d14 100644
--- a/src/utils/lsp/__tests__/command-manager.ts
+++ b/src/utils/lsp/__tests__/command-manager.ts
@@ -158,4 +158,122 @@ describe('CommandManager', () => {
 			error,
 		});
 	});
+
+	it('should return disposables when registering commands', () => {
+		const manager = new CommandManager(mockConnection, mockLogger);
+		const disposable = manager.on('test', () => undefined);
+
+		expect(disposable).toHaveProperty('dispose');
+		expect(disposable.dispose).toBeInstanceOf(Function);
+	});
+
+	it("should deregister the command handler when disposing a command's disposable", async () => {
+		const manager = new CommandManager(mockConnection, mockLogger);
+		const disposable = manager.on('test', () => undefined);
+
+		manager.register();
+		disposable.dispose();
+
+		const executeCommand = mockConnection.onExecuteCommand.mock.calls[0][0];
+
+		await executeCommand(
+			{
+				command: 'test',
+				arguments: [1, 2, 3],
+			},
+			{
+				isCancellationRequested: false,
+			} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		expect(mockLogger.debug).toHaveBeenCalledWith('No handler registered for command', {
+			command: 'test',
+		});
+	});
+	it("should deregister the command handler for all command names when disposing a command's disposable", async () => {
+		const manager = new CommandManager(mockConnection, mockLogger);
+		const disposable = manager.on(['test', 'other'], () => undefined);
+
+		manager.register();
+		disposable.dispose();
+
+		const executeCommand = mockConnection.onExecuteCommand.mock.calls[0][0];
+
+		await executeCommand(
+			{
+				command: 'test',
+				arguments: [1, 2, 3],
+			},
+			{
+				isCancellationRequested: false,
+			} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		await executeCommand(
+			{
+				command: 'other',
+				arguments: [1, 2, 3],
+			},
+			{
+				isCancellationRequested: false,
+			} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		expect(mockLogger.debug).toHaveBeenCalledWith('No handler registered for command', {
+			command: 'test',
+		});
+	});
+
+	it('should be disposable', () => {
+		const manager = new CommandManager(mockConnection, mockLogger);
+
+		expect(manager).toHaveProperty('dispose');
+		expect(manager.dispose).toBeInstanceOf(Function);
+	});
+
+	it('should deregister all registered command handlers when disposing', () => {
+		const manager = new CommandManager(mockConnection, mockLogger);
+
+		manager.on('test1', () => undefined);
+		manager.on('test2', () => undefined);
+		manager.register();
+		manager.dispose();
+
+		const executeCommand =
+			mockConnection.onExecuteCommand.mock.calls[
+				mockConnection.onExecuteCommand.mock.calls.length - 1
+			][0];
+
+		executeCommand(
+			{
+				command: 'test1',
+				arguments: [1, 2, 3],
+			},
+			{
+				isCancellationRequested: false,
+			} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		executeCommand(
+			{
+				command: 'test2',
+				arguments: [1, 2, 3],
+			},
+			{
+				isCancellationRequested: false,
+			} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		expect(mockLogger.debug).not.toHaveBeenCalledWith('No handler registered for command', {
+			command: 'test1',
+		});
+		expect(mockLogger.debug).not.toHaveBeenCalledWith('No handler registered for command', {
+			command: 'test2',
+		});
+	});
 });
diff --git a/src/utils/lsp/__tests__/notification-manager.ts b/src/utils/lsp/__tests__/notification-manager.ts
index 8be3e54..13ce159 100644
--- a/src/utils/lsp/__tests__/notification-manager.ts
+++ b/src/utils/lsp/__tests__/notification-manager.ts
@@ -140,4 +140,58 @@ describe('NotificationManager', () => {
 			error,
 		});
 	});
+
+	it('should return disposables when registering notifications', () => {
+		const manager = new NotificationManager(mockConnection);
+		const disposable = manager.on('test', () => undefined);
+
+		expect(disposable).toHaveProperty('dispose');
+		expect(disposable.dispose).toBeInstanceOf(Function);
+	});
+
+	it("should deregister the notification handler when disposing a notification's disposable", () => {
+		const manager = new NotificationManager(mockConnection);
+		const handler = jest.fn();
+
+		manager.on('test', handler).dispose();
+
+		const onNotificationHandler = mockConnectionBase.onNotification.mock.calls[0][1];
+
+		onNotificationHandler({ prop: 'test' }, 'test');
+
+		expect(handler).not.toHaveBeenCalled();
+	});
+
+	it('should be disposable', () => {
+		const manager = new NotificationManager(mockConnection);
+
+		expect(manager).toHaveProperty('dispose');
+		expect(manager.dispose).toBeInstanceOf(Function);
+	});
+
+	it('should dispose of all notification handlers when disposed', () => {
+		const manager = new NotificationManager(mockConnection);
+		const handler1 = jest.fn();
+		const handler2 = jest.fn();
+
+		manager.on('test', handler1);
+		manager.on(handler2);
+
+		manager.dispose();
+
+		const onNotificationHandler =
+			mockConnectionBase.onNotification.mock.calls[
+				mockConnectionBase.onNotification.mock.calls.length - 2
+			][1];
+		const onStarNotificationHandler =
+			mockConnectionBase.onNotification.mock.calls[
+				mockConnectionBase.onNotification.mock.calls.length - 1
+			][0];
+
+		onNotificationHandler({ prop: 'test' }, 'test');
+		onStarNotificationHandler({ prop: 'test' }, 'test');
+
+		expect(handler1).not.toHaveBeenCalled();
+		expect(handler2).not.toHaveBeenCalled();
+	});
 });
diff --git a/src/utils/stylelint/__tests__/__snapshots__/stylelint-runner.ts.snap b/src/utils/stylelint/__tests__/__snapshots__/stylelint-runner.ts.snap
index eb93330..51b53eb 100644
--- a/src/utils/stylelint/__tests__/__snapshots__/stylelint-runner.ts.snap
+++ b/src/utils/stylelint/__tests__/__snapshots__/stylelint-runner.ts.snap
@@ -31,7 +31,7 @@ Object {
       "message": "Unexpected empty block (block-no-empty)",
       "range": Object {
         "end": Object {
-          "character": 3,
+          "character": 4,
           "line": 0,
         },
         "start": Object {
diff --git a/src/utils/stylelint/__tests__/__snapshots__/warning-to-diagnostic.ts.snap b/src/utils/stylelint/__tests__/__snapshots__/warning-to-diagnostic.ts.snap
index 0d9a8b2..3bd2e79 100644
--- a/src/utils/stylelint/__tests__/__snapshots__/warning-to-diagnostic.ts.snap
+++ b/src/utils/stylelint/__tests__/__snapshots__/warning-to-diagnostic.ts.snap
@@ -47,7 +47,7 @@ Object {
   "message": "Expected \\"#bbbbbb\\" to be \\"#bbb\\" (color-hex-length)",
   "range": Object {
     "end": Object {
-      "character": 19,
+      "character": 25,
       "line": 2,
     },
     "start": Object {
diff --git a/test/e2e/__tests__/__snapshots__/code-actions.ts.snap b/test/e2e/__tests__/__snapshots__/code-actions.ts.snap
index 4cdf6bf..9cb0cf8 100644
--- a/test/e2e/__tests__/__snapshots__/code-actions.ts.snap
+++ b/test/e2e/__tests__/__snapshots__/code-actions.ts.snap
@@ -19,6 +19,20 @@ exports[`Code actions should disable rules for a specific line with a comment on
 "
 `;
 
+exports[`Code actions should disable rules for a specific line with a comment on the same line 2`] = `
+"#!/usr/bin/env node
+/* stylelint-disable color-no-invalid-hex */
+/* eslint-disable node/shebang */
+'use strict';
+
+const css = css\`
+	.foo {
+		color: #00;
+	}
+\`;
+"
+`;
+
 exports[`Code actions should disable rules for an entire file 1`] = `
 "/* stylelint-disable indentation */
 a {
@@ -121,3 +135,12 @@ Array [
   },
 ]
 `;
+
+exports[`Code actions should run auto-fix action on save 1`] = `
+"a {
+    font-size: 1.2em;
+    /* stylelint-disable-next-line comment-no-empty */
+    color: #00;
+}
+"
+`;
diff --git a/test/e2e/__tests__/__snapshots__/config-basedir.ts.snap b/test/e2e/__tests__/__snapshots__/config-basedir.ts.snap
index 0e760b2..df058eb 100644
--- a/test/e2e/__tests__/__snapshots__/config-basedir.ts.snap
+++ b/test/e2e/__tests__/__snapshots__/config-basedir.ts.snap
@@ -29,7 +29,7 @@ Array [
     "message": "Expected indentation of 8 spaces (indentation)",
     "range": Object {
       "end": Object {
-        "character": 3,
+        "character": 14,
         "line": 1,
       },
       "start": Object {
diff --git a/test/e2e/__tests__/__snapshots__/config-file.ts.snap b/test/e2e/__tests__/__snapshots__/config-file.ts.snap
index d39f18f..04c1a47 100644
--- a/test/e2e/__tests__/__snapshots__/config-file.ts.snap
+++ b/test/e2e/__tests__/__snapshots__/config-file.ts.snap
@@ -10,7 +10,7 @@ Array [
     "message": "Unexpected empty block (block-no-empty)",
     "range": Object {
       "end": Object {
-        "character": 3,
+        "character": 4,
         "line": 0,
       },
       "start": Object {
diff --git a/test/e2e/__tests__/__snapshots__/ignore-disables.ts.snap b/test/e2e/__tests__/__snapshots__/ignore-disables.ts.snap
index b59647d..fa82bc8 100644
--- a/test/e2e/__tests__/__snapshots__/ignore-disables.ts.snap
+++ b/test/e2e/__tests__/__snapshots__/ignore-disables.ts.snap
@@ -29,7 +29,7 @@ Array [
     "message": "Expected indentation of 4 spaces (indentation)",
     "range": Object {
       "end": Object {
-        "character": 3,
+        "character": 14,
         "line": 3,
       },
       "start": Object {
diff --git a/test/e2e/__tests__/__snapshots__/lint.ts.snap b/test/e2e/__tests__/__snapshots__/lint.ts.snap
index 2ae3651..5755060 100644
--- a/test/e2e/__tests__/__snapshots__/lint.ts.snap
+++ b/test/e2e/__tests__/__snapshots__/lint.ts.snap
@@ -26,7 +26,7 @@ Array [
     "message": "Unexpected invalid hex color \\"#y3\\" (color-no-invalid-hex)",
     "range": Object {
       "end": Object {
-        "character": 12,
+        "character": 14,
         "line": 6,
       },
       "start": Object {
@@ -45,7 +45,7 @@ Array [
     "message": "Expected indentation of 4 spaces (indentation)",
     "range": Object {
       "end": Object {
-        "character": 3,
+        "character": 13,
         "line": 2,
       },
       "start": Object {
@@ -69,7 +69,7 @@ Array [
     "message": "Expected indentation of 4 spaces (indentation)",
     "range": Object {
       "end": Object {
-        "character": 3,
+        "character": 13,
         "line": 2,
       },
       "start": Object {
diff --git a/test/e2e/__tests__/__snapshots__/report-descriptionless-disables.ts.snap b/test/e2e/__tests__/__snapshots__/report-descriptionless-disables.ts.snap
index 602fefa..7f1d464 100644
--- a/test/e2e/__tests__/__snapshots__/report-descriptionless-disables.ts.snap
+++ b/test/e2e/__tests__/__snapshots__/report-descriptionless-disables.ts.snap
@@ -7,7 +7,7 @@ Array [
     "message": "Disable for \\"indentation\\" is missing a description",
     "range": Object {
       "end": Object {
-        "character": 5,
+        "character": 48,
         "line": 2,
       },
       "start": Object {
diff --git a/test/e2e/__tests__/__snapshots__/report-invalid-scope-disables.ts.snap b/test/e2e/__tests__/__snapshots__/report-invalid-scope-disables.ts.snap
index e2b55ee..326ae8e 100644
--- a/test/e2e/__tests__/__snapshots__/report-invalid-scope-disables.ts.snap
+++ b/test/e2e/__tests__/__snapshots__/report-invalid-scope-disables.ts.snap
@@ -7,7 +7,7 @@ Array [
     "message": "Rule \\"foo\\" isn't enabled",
     "range": Object {
       "end": Object {
-        "character": 1,
+        "character": 36,
         "line": 0,
       },
       "start": Object {
@@ -23,7 +23,7 @@ Array [
     "message": "Rule \\"foo\\" isn't enabled",
     "range": Object {
       "end": Object {
-        "character": 1,
+        "character": 31,
         "line": 2,
       },
       "start": Object {
@@ -39,7 +39,7 @@ Array [
     "message": "Rule \\"foo\\" isn't enabled",
     "range": Object {
       "end": Object {
-        "character": 1,
+        "character": 26,
         "line": 4,
       },
       "start": Object {
diff --git a/test/e2e/__tests__/__snapshots__/report-needless-disables.ts.snap b/test/e2e/__tests__/__snapshots__/report-needless-disables.ts.snap
index 5bf7aa0..65f56e0 100644
--- a/test/e2e/__tests__/__snapshots__/report-needless-disables.ts.snap
+++ b/test/e2e/__tests__/__snapshots__/report-needless-disables.ts.snap
@@ -7,7 +7,7 @@ Array [
     "message": "Needless disable for \\"indentation\\"",
     "range": Object {
       "end": Object {
-        "character": 3,
+        "character": 55,
         "line": 2,
       },
       "start": Object {
@@ -23,7 +23,7 @@ Array [
     "message": "Needless disable for \\"indentation\\"",
     "range": Object {
       "end": Object {
-        "character": 1,
+        "character": 34,
         "line": 6,
       },
       "start": Object {
@@ -39,7 +39,7 @@ Array [
     "message": "Needless disable for \\"indentation\\"",
     "range": Object {
       "end": Object {
-        "character": 17,
+        "character": 55,
         "line": 14,
       },
       "start": Object {
@@ -55,7 +55,7 @@ Array [
     "message": "Needless disable for \\"indentation\\"",
     "range": Object {
       "end": Object {
-        "character": 1,
+        "character": 34,
         "line": 17,
       },
       "start": Object {
@@ -71,7 +71,7 @@ Array [
     "message": "Needless disable for \\"unknown\\"",
     "range": Object {
       "end": Object {
-        "character": 3,
+        "character": 55,
         "line": 2,
       },
       "start": Object {
diff --git a/test/e2e/__tests__/__snapshots__/validate.ts.snap b/test/e2e/__tests__/__snapshots__/validate.ts.snap
index 8827f0d..560d6bc 100644
--- a/test/e2e/__tests__/__snapshots__/validate.ts.snap
+++ b/test/e2e/__tests__/__snapshots__/validate.ts.snap
@@ -10,7 +10,7 @@ Array [
     "message": "Expected indentation of 4 spaces (indentation)",
     "range": Object {
       "end": Object {
-        "character": 3,
+        "character": 13,
         "line": 2,
       },
       "start": Object {
diff --git a/test/e2e/__tests__/code-actions.ts b/test/e2e/__tests__/code-actions.ts
index 20f8849..96e6a78 100644
--- a/test/e2e/__tests__/code-actions.ts
+++ b/test/e2e/__tests__/code-actions.ts
@@ -1,4 +1,5 @@
 /* eslint-disable jest/no-standalone-expect */
+import fs from 'fs/promises';
 import path from 'path';
 
 import * as JSONC from 'jsonc-parser';
@@ -34,10 +35,22 @@ const jsPath = path.resolve(workspaceDir, 'code-actions/test.js');
 const settingsPath = path.resolve(workspaceDir, 'code-actions/.vscode/settings.json');
 
 const defaultSettings = {
+	'[css]': {
+		'editor.codeActionsOnSave': {
+			'source.fixAll.stylelint': true,
+		},
+	},
 	'stylelint.reportNeedlessDisables': true,
 	'stylelint.validate': ['css', 'javascript'],
 };
 
+const css = `a {
+  font-size: 1.2em;
+    /* stylelint-disable-next-line comment-no-empty */
+  color: #00;
+}
+`;
+
 // TODO: Investigate why editing tests intermittently fail on CI
 const localIt = process.env.CI ? it.skip : it;
 
@@ -49,6 +62,8 @@ describe('Code actions', () => {
 	});
 
 	afterEach(async () => {
+		await fs.writeFile(cssPath, css);
+
 		const settingsEditor = await openDocument(settingsPath);
 		const text = settingsEditor.document.getText();
 		const settings = JSONC.parse(text) as unknown;
@@ -105,6 +120,23 @@ describe('Code actions', () => {
 		expect(actions).toHaveLength(0);
 	});
 
+	test('should run auto-fix action on save', async () => {
+		const editor = await openDocument(cssPath);
+
+		await waitForDiagnostics(editor);
+
+		await getCodeActions(editor);
+
+		// API won't save unless we dirty the document, unlike saving via the UI
+		await editor.edit((editBuilder) => {
+			editBuilder.insert(new Position(3, 0), ' ');
+		});
+
+		await editor.document.save();
+
+		expect(editor.document.getText()).toMatchSnapshot();
+	});
+
 	localIt('should disable rules for an entire file', async () => {
 		const editor = await openDocument(cssPath);
 
@@ -173,7 +205,7 @@ describe('Code actions', () => {
 
 		await settingsEditor.edit((edit) =>
 			edit.insert(
-				new Position(5, 2),
+				new Position(10, 2),
 				',\n\t"stylelint.codeAction.disableRuleComment": { "location": "sameLine" }',
 			),
 		);
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..49bd141 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', {
@@ -20,7 +19,7 @@ Object.defineProperty(global, 'workspaceDir', {
 	},
 });
 
-let openEditors: TextEditor[] = [];
+const openEditors: TextEditor[] = [];
 
 Object.defineProperty(global, 'openDocument', {
 	get() {
@@ -124,7 +123,7 @@ global.afterEach(async () => {
 		await commands.executeCommand('workbench.action.files.revert');
 	}
 
-	openEditors = [];
+	openEditors.length = 0;
 
 	await commands.executeCommand('workbench.action.closeAllEditors');
 });
diff --git a/test/e2e/workspace/code-actions/.vscode/settings.json b/test/e2e/workspace/code-actions/.vscode/settings.json
index 1c808bb..caa129a 100644
--- a/test/e2e/workspace/code-actions/.vscode/settings.json
+++ b/test/e2e/workspace/code-actions/.vscode/settings.json
@@ -1,4 +1,9 @@
 {
+	"[css]": {
+		"editor.codeActionsOnSave": {
+			"source.fixAll.stylelint": true
+		}
+	},
 	"stylelint.reportNeedlessDisables": true,
 	"stylelint.validate": [
 		"css",
diff --git a/test/integration/connection-manager.ts b/test/integration/connection-manager.ts
new file mode 100644
index 0000000..9e36883
--- /dev/null
+++ b/test/integration/connection-manager.ts
@@ -0,0 +1,143 @@
+// Part of test utils, don't record coverage
+/* istanbul ignore file */
+
+import { Duplex } from 'stream';
+import * as LSP from 'vscode-languageserver-protocol';
+import {
+	Connection,
+	StreamMessageReader,
+	StreamMessageWriter,
+	WatchDog,
+} from 'vscode-languageserver/node';
+import { createConnection } from 'vscode-languageserver/lib/common/server';
+
+class TestStream extends Duplex {
+	_write(chunk: string, _encoding: string, done: () => void) {
+		this.emit('data', chunk);
+		done();
+	}
+
+	_read() {
+		// do nothing
+	}
+}
+
+/**
+ * Creates a watch dog that doesn't do anything other than ending the connection
+ * when `exit()` is called.
+ */
+function createWatchDog(connection: LSP.ProtocolConnection): WatchDog {
+	let shutdownReceived = false;
+
+	return {
+		initialize: () => undefined,
+
+		get shutdownReceived(): boolean {
+			return shutdownReceived;
+		},
+
+		set shutdownReceived(value: boolean) {
+			shutdownReceived = value;
+		},
+
+		exit: (): void => {
+			if (!connection) {
+				return;
+			}
+
+			try {
+				connection.end();
+			} catch {
+				// ignore
+			}
+		},
+	};
+}
+
+/**
+ * Manages connections used for testing the language server.
+ */
+export class ConnectionManager {
+	#serverProtocolConnection?: LSP.ProtocolConnection;
+	#serverConnection?: Connection;
+	#clientProtocolConnection?: LSP.ProtocolConnection;
+
+	initialize() {
+		const up = new TestStream();
+		const down = new TestStream();
+		const downReader = new StreamMessageReader(down);
+		const upReader = new StreamMessageReader(up);
+		const downWriter = new StreamMessageWriter(down);
+		const upWriter = new StreamMessageWriter(up);
+		const serverProtocolConnection = LSP.createProtocolConnection(upReader, downWriter);
+		const clientProtocolConnection = LSP.createProtocolConnection(downReader, upWriter);
+		const watchDog = createWatchDog(serverProtocolConnection);
+
+		this.#serverConnection = createConnection(() => serverProtocolConnection, watchDog);
+		this.#serverProtocolConnection = serverProtocolConnection;
+		this.#clientProtocolConnection = clientProtocolConnection;
+	}
+
+	get serverConnection(): Connection {
+		if (!this.#serverConnection) {
+			throw new Error('Server connection not initialized');
+		}
+
+		return this.#serverConnection;
+	}
+
+	get serverProtocolConnection(): LSP.ProtocolConnection {
+		if (!this.#serverProtocolConnection) {
+			throw new Error('Server connection not initialized');
+		}
+
+		return this.#serverProtocolConnection;
+	}
+
+	get clientProtocolConnection(): LSP.ProtocolConnection {
+		if (!this.#clientProtocolConnection) {
+			throw new Error('Client connection not initialized');
+		}
+
+		return this.#clientProtocolConnection;
+	}
+
+	async shutdown() {
+		let timeoutRef: NodeJS.Timeout | undefined = undefined;
+		const timeout = new Promise<void>((resolve) => {
+			timeoutRef = setTimeout(resolve, 3000);
+		});
+		const shutdown = (async () => {
+			await this.clientProtocolConnection.sendRequest(LSP.ShutdownRequest.type);
+			// Currently, sendNotification doesn't return a promise, so if we
+			// send an exit notification, we are unable to wait for the server
+			// to exit. As a result, the connection gets closed further down
+			// before the exit notification is sent, and once it makes its way
+			// through the message queue, it results in a write after end error.
+			//
+			// The upcoming vscode-jsonrpc version will return promises for
+			// sendNotification, so we can wait for the notification to be sent.
+			// For now, we'll just skip the exit notification and kill the
+			// connection anyway.
+			// this.clientProtocolConnection.sendNotification(LSP.ExitNotification.type);
+
+			return this.clientProtocolConnection;
+		})();
+
+		const connection = await Promise.race([timeout, shutdown]);
+
+		try {
+			if (connection) {
+				connection.end();
+				connection.dispose();
+				timeoutRef && clearTimeout(timeoutRef);
+			} else {
+				throw new Error('Stopping server timed out');
+			}
+		} finally {
+			this.serverConnection.dispose();
+			this.serverProtocolConnection.dispose();
+			this.clientProtocolConnection.dispose();
+		}
+	}
+}
diff --git a/test/integration/jest.config.js b/test/integration/jest.config.js
index ea3e97c..915a5e5 100644
--- a/test/integration/jest.config.js
+++ b/test/integration/jest.config.js
@@ -2,10 +2,14 @@
 
 /** @type {import('@jest/types').Config.InitialOptions} */
 const config = {
-	testMatch: ['<rootDir>/*/test.[jt]s?(x)'],
+	testMatch: [
+		'<rootDir>/stylelint-vscode/test.[jt]s?(x)',
+		'<rootDir>/server/__tests__/**/*.[jt]s?(x)',
+	],
 	preset: 'ts-jest',
 	globals: { 'ts-jest': { tsconfig: '<rootDir>/../../tsconfig.test.json' } },
 	verbose: true,
+	setupFilesAfterEnv: ['<rootDir>/setup.ts'],
 	modulePathIgnorePatterns: [
 		'<rootDir>/../../.vscode-test',
 		'<rootDir>/../e2e/workspace/defaults/yarn-[^/]+/stylelint',
diff --git a/test/integration/server/__tests__/initialization.ts b/test/integration/server/__tests__/initialization.ts
new file mode 100644
index 0000000..709c46b
--- /dev/null
+++ b/test/integration/server/__tests__/initialization.ts
@@ -0,0 +1,42 @@
+import * as LSP from 'vscode-languageserver-protocol';
+import { StylelintLanguageServer } from '../../../../src/server';
+
+describe('Initialization', () => {
+	const connectionManager = new ConnectionManager();
+	let server: StylelintLanguageServer;
+	let connection: LSP.ProtocolConnection;
+
+	beforeAll(() => {
+		connectionManager.initialize();
+		server = new StylelintLanguageServer({ connection: connectionManager.serverConnection });
+		server.start();
+		connectionManager.clientProtocolConnection.listen();
+		connection = connectionManager.clientProtocolConnection;
+	});
+
+	afterAll(async () => {
+		await connectionManager.shutdown();
+	});
+
+	it('should respond to InitializeRequest with an InitializeResult', async () => {
+		const init: LSP.InitializeParams = {
+			rootUri: 'file:///home/test/test.css',
+			processId: 1,
+			capabilities: {},
+			workspaceFolders: null,
+		};
+
+		const result = await connection.sendRequest(LSP.InitializeRequest.type, init);
+
+		expect(result).toMatchInlineSnapshot(`
+		Object {
+		  "capabilities": Object {
+		    "textDocumentSync": Object {
+		      "change": 1,
+		      "openClose": true,
+		    },
+		  },
+		}
+	`);
+	});
+});
diff --git a/test/integration/setup.ts b/test/integration/setup.ts
new file mode 100644
index 0000000..d9acf84
--- /dev/null
+++ b/test/integration/setup.ts
@@ -0,0 +1,7 @@
+import { ConnectionManager } from './connection-manager';
+
+Object.defineProperty(global, 'ConnectionManager', {
+	get() {
+		return ConnectionManager;
+	},
+});
diff --git a/test/integration/stylelint-vscode/__snapshots__/test.ts.snap b/test/integration/stylelint-vscode/__snapshots__/test.ts.snap
index 0684945..d14ca11 100644
--- a/test/integration/stylelint-vscode/__snapshots__/test.ts.snap
+++ b/test/integration/stylelint-vscode/__snapshots__/test.ts.snap
@@ -1,29 +1,5 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`StylelintRunner should be resolved with diagnostic plugin rule URL 1`] = `
-Array [
-  Object {
-    "code": "scss/at-rule-no-unknown",
-    "codeDescription": Object {
-      "href": "https://github.com/stylelint-scss/stylelint-scss/blob/master/src/rules/at-rule-no-unknown",
-    },
-    "message": "Unexpected unknown at-rule \\"@unknown\\" (scss/at-rule-no-unknown)",
-    "range": Object {
-      "end": Object {
-        "character": 1,
-        "line": 0,
-      },
-      "start": Object {
-        "character": 0,
-        "line": 0,
-      },
-    },
-    "severity": 1,
-    "source": "Stylelint",
-  },
-]
-`;
-
 exports[`StylelintRunner should be resolved with diagnostics when it lints CSS successfully 1`] = `
 Array [
   Object {
@@ -53,7 +29,7 @@ Array [
     "message": "Expected indentation of 0 tabs (indentation)",
     "range": Object {
       "end": Object {
-        "character": 3,
+        "character": 14,
         "line": 0,
       },
       "start": Object {
@@ -74,7 +50,7 @@ Array [
     "message": "Unknown rule this-rule-does-not-exist.",
     "range": Object {
       "end": Object {
-        "character": 1,
+        "character": 4,
         "line": 0,
       },
       "start": Object {
@@ -90,7 +66,7 @@ Array [
     "message": "Unknown rule this-rule-also-does-not-exist.",
     "range": Object {
       "end": Object {
-        "character": 1,
+        "character": 4,
         "line": 0,
       },
       "start": Object {
@@ -182,7 +158,7 @@ Array [
     "message": "Expected \\"bold\\" to be \\"700\\" (font-weight-notation)",
     "range": Object {
       "end": Object {
-        "character": 30,
+        "character": 33,
         "line": 2,
       },
       "start": Object {
@@ -201,7 +177,7 @@ Array [
     "message": "Expected \\"normal\\" to be \\"400\\" (font-weight-notation)",
     "range": Object {
       "end": Object {
-        "character": 7,
+        "character": 12,
         "line": 4,
       },
       "start": Object {
@@ -225,7 +201,7 @@ Array [
     "message": "Unexpected unit (length-zero-no-unit)",
     "range": Object {
       "end": Object {
-        "character": 11,
+        "character": 12,
         "line": 3,
       },
       "start": Object {
@@ -250,7 +226,7 @@ Object {
       "message": "Unexpected unknown type selector \\"unknown\\" (selector-type-no-unknown)",
       "range": Object {
         "end": Object {
-          "character": 1,
+          "character": 7,
           "line": 1,
         },
         "start": Object {
@@ -306,7 +282,7 @@ Object {
       "message": "Expected indentation of 2 spaces (indentation)",
       "range": Object {
         "end": Object {
-          "character": 4,
+          "character": 12,
           "line": 1,
         },
         "start": Object {
@@ -329,7 +305,7 @@ Object {
       "message": "Disable for \\"indentation\\" is missing a description",
       "range": Object {
         "end": Object {
-          "character": 5,
+          "character": 48,
           "line": 2,
         },
         "start": Object {
@@ -345,7 +321,7 @@ Object {
       "message": "Disable for \\"indentation\\" is missing a description",
       "range": Object {
         "end": Object {
-          "character": 1,
+          "character": 34,
           "line": 5,
         },
         "start": Object {
@@ -361,7 +337,7 @@ Object {
       "message": "Disable for \\"indentation\\" is missing a description",
       "range": Object {
         "end": Object {
-          "character": 15,
+          "character": 53,
           "line": 11,
         },
         "start": Object {
@@ -384,7 +360,7 @@ Object {
       "message": "Rule \\"foo\\" isn't enabled",
       "range": Object {
         "end": Object {
-          "character": 1,
+          "character": 36,
           "line": 1,
         },
         "start": Object {
@@ -400,7 +376,7 @@ Object {
       "message": "Rule \\"foo\\" isn't enabled",
       "range": Object {
         "end": Object {
-          "character": 1,
+          "character": 31,
           "line": 3,
         },
         "start": Object {
@@ -416,7 +392,7 @@ Object {
       "message": "Rule \\"foo\\" isn't enabled",
       "range": Object {
         "end": Object {
-          "character": 1,
+          "character": 26,
           "line": 5,
         },
         "start": Object {
@@ -442,7 +418,7 @@ Object {
       "message": "Expected indentation of 4 spaces (indentation)",
       "range": Object {
         "end": Object {
-          "character": 3,
+          "character": 47,
           "line": 2,
         },
         "start": Object {
@@ -458,7 +434,7 @@ Object {
       "message": "Needless disable for \\"indentation\\"",
       "range": Object {
         "end": Object {
-          "character": 3,
+          "character": 46,
           "line": 2,
         },
         "start": Object {
@@ -474,7 +450,7 @@ Object {
       "message": "Needless disable for \\"indentation\\"",
       "range": Object {
         "end": Object {
-          "character": 1,
+          "character": 34,
           "line": 6,
         },
         "start": Object {
@@ -490,7 +466,7 @@ Object {
       "message": "Needless disable for \\"indentation\\"",
       "range": Object {
         "end": Object {
-          "character": 17,
+          "character": 55,
           "line": 13,
         },
         "start": Object {
@@ -506,7 +482,7 @@ Object {
       "message": "Needless disable for \\"indentation\\"",
       "range": Object {
         "end": Object {
-          "character": 1,
+          "character": 34,
           "line": 16,
         },
         "start": Object {
@@ -555,7 +531,7 @@ Object {
       "message": "Expected indentation of 2 spaces (indentation)",
       "range": Object {
         "end": Object {
-          "character": 4,
+          "character": 12,
           "line": 1,
         },
         "start": Object {
diff --git a/test/integration/stylelint-vscode/test.ts b/test/integration/stylelint-vscode/test.ts
index 36e2e65..a3bcb9d 100644
--- a/test/integration/stylelint-vscode/test.ts
+++ b/test/integration/stylelint-vscode/test.ts
@@ -3,6 +3,7 @@ import { pathToFileURL } from 'url';
 import { TextDocument } from 'vscode-languageserver-textdocument';
 import { StylelintRunner } from '../../../src/utils/stylelint';
 import { version as stylelintVersion } from 'stylelint/package.json';
+import { version as stylelintScssVersion } from 'stylelint-scss/package.json';
 import semver from 'semver';
 
 const createDocument = (uri: string | null, languageId: string, contents: string): TextDocument =>
@@ -264,23 +265,45 @@ a { color: #000 }
 		expect(result.diagnostics).toMatchSnapshot();
 	});
 
-	test('should be resolved with diagnostic plugin rule URL', async () => {
-		expect.assertions(1);
-		const runner = new StylelintRunner();
-		const result = await runner.lintDocument(
-			createDocument('unknown-rule.scss', 'scss', '@unknown (max-width: 960px) {}'),
-			{
-				config: {
-					plugins: ['stylelint-scss'],
-					rules: {
-						'scss/at-rule-no-unknown': true,
+	if (semver.satisfies(stylelintScssVersion, '^15')) {
+		test('should be resolved with diagnostic plugin rule URL', async () => {
+			expect.assertions(1);
+			const runner = new StylelintRunner();
+			const result = await runner.lintDocument(
+				createDocument('unknown-rule.scss', 'scss', '@unknown (max-width: 960px) {}'),
+				{
+					config: {
+						plugins: ['stylelint-scss'],
+						rules: {
+							'scss/at-rule-no-unknown': true,
+						},
 					},
 				},
-			},
-		);
+			);
 
-		expect(result.diagnostics).toMatchSnapshot();
-	});
+			expect(result.diagnostics).toEqual([
+				{
+					code: 'scss/at-rule-no-unknown',
+					codeDescription: {
+						href: 'https://github.com/stylelint-scss/stylelint-scss/blob/master/src/rules/at-rule-no-unknown',
+					},
+					message: 'Unexpected unknown at-rule "@unknown" (scss/at-rule-no-unknown)',
+					range: {
+						end: {
+							character: 8,
+							line: 0,
+						},
+						start: {
+							character: 0,
+							line: 0,
+						},
+					},
+					severity: 1,
+					source: 'Stylelint',
+				},
+			]);
+		});
+	}
 });
 
 describe('StylelintRunner with a configuration file', () => {
diff --git a/test/types.d.ts b/test/types.d.ts
index a1b07d3..fdecbcb 100644
--- a/test/types.d.ts
+++ b/test/types.d.ts
@@ -9,7 +9,8 @@ import type * as ProcessesActual from '../src/utils/processes';
 import type { OptionalExcept } from '../src/utils/types';
 import type { TracerFn } from '../src/utils/packages';
 import type * as ServerMocks from './unit/server-mocks';
-import { ExtensionEvents } from '../src/extension';
+import type { ConnectionManager as ConnectionManagerConstructor } from './integration/connection-manager';
+import type { ExtensionEvents } from '../src/extension';
 
 declare global {
 	/**
@@ -22,6 +23,11 @@ declare global {
 	 */
 	const serverMocks: typeof ServerMocks;
 
+	/**
+	 * Connection manager. Only available in integration tests.
+	 */
+	const ConnectionManager: typeof ConnectionManagerConstructor;
+
 	/**
 	 * Opens a file in the editor. Only available in end-to-end tests.
 	 * @param fsPath The file path.
diff --git a/test/unit/server-mocks.ts b/test/unit/server-mocks.ts
index 0863c23..d003076 100644
--- a/test/unit/server-mocks.ts
+++ b/test/unit/server-mocks.ts
@@ -1,127 +1,93 @@
 // Part of test utils, don't record coverage
 /* istanbul ignore file */
-// cspell:ignore crit,emerg,unhandle
+// cspell:ignore crit,emerg,unhandle,unregistration
 
 import type { TextDocument } from 'vscode-languageserver-textdocument';
-import type { Connection, TextDocuments } from 'vscode-languageserver';
+import type LSP from 'vscode-languageserver-protocol';
+import type {
+	BulkRegistration,
+	BulkUnregistration,
+	Connection,
+	TextDocuments,
+} from 'vscode-languageserver';
 import type winston from 'winston';
 import type { LanguageServerContext, LanguageServerOptions } from '../../src/server/types';
 import type { CommandManager, NotificationManager } from '../../src/utils/lsp';
 import type { StylelintRunner } from '../../src/utils/stylelint';
+import { MaybeAsync, PublicOnly, PublicOnlyDeep } from '../../src/utils/types';
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type JestFn = jest.Mock<any, any>;
 
-export type MockConnection = {
+export type MockConnection = jest.Mocked<PublicOnly<Connection>> & {
 	__typed: () => Connection;
-	client: {
+	client: jest.Mocked<PublicOnly<Connection['client']>> & {
 		connection: MockConnection;
-		register: JestFn;
+		register: jest.MockInstance<
+			Promise<LSP.Disposable | BulkRegistration>,
+			| [type: LSP.ProtocolNotificationType<unknown, unknown>, registerParams?: unknown]
+			| [type: LSP.ProtocolNotificationType0<unknown>, registerParams?: unknown]
+			| [
+					unregistration: BulkUnregistration,
+					type: LSP.ProtocolNotificationType<unknown, unknown>,
+					registerParams?: unknown,
+			  ]
+			| [
+					unregistration: BulkUnregistration,
+					type: LSP.ProtocolNotificationType0<unknown>,
+					registerParams?: unknown,
+			  ]
+			| [
+					type: LSP.ProtocolRequestType<unknown, unknown, unknown, unknown, unknown>,
+					registerParams?: unknown,
+			  ]
+			| [
+					type: LSP.ProtocolRequestType0<unknown, unknown, unknown, unknown>,
+					registerParams?: unknown,
+			  ]
+			| [
+					unregistration: BulkUnregistration,
+					type: LSP.ProtocolRequestType<unknown, unknown, unknown, unknown, unknown>,
+					registerParams?: unknown,
+			  ]
+			| [
+					unregistration: BulkUnregistration,
+					type: LSP.ProtocolRequestType0<unknown, unknown, unknown, unknown>,
+					registerParams?: unknown,
+			  ]
+			| [type: LSP.RegistrationType<unknown>, registerParams?: unknown]
+			| [
+					unregistration: BulkUnregistration,
+					type: LSP.RegistrationType<unknown>,
+					registerParams?: unknown,
+			  ]
+			| [registrations: BulkRegistration]
+		>;
 	};
-	console: {
+	console: jest.Mocked<PublicOnly<Connection['console']>> & {
 		connection: MockConnection;
-		error: JestFn;
-		info: JestFn;
-		log: JestFn;
-		warn: JestFn;
 	};
-	dispose: JestFn;
-	languages: {
+	languages: jest.Mocked<PublicOnly<Connection['languages']>> & {
 		connection: MockConnection;
-		attachPartialResultProgress: JestFn;
-		attachWorkDoneProgress: JestFn;
-		callHierarchy: {
-			onIncomingCalls: JestFn;
-			onOutgoingCalls: JestFn;
-			onPrepare: JestFn;
-		};
-		moniker: {
-			on: JestFn;
-		};
-		onLinkedEditingRange: JestFn;
-		semanticTokens: {
-			on: JestFn;
-			onDelta: JestFn;
-			onRange: JestFn;
-		};
+		callHierarchy: jest.Mocked<PublicOnly<Connection['languages']['callHierarchy']>>;
+		moniker: jest.Mocked<PublicOnly<Connection['languages']['moniker']>>;
+		semanticTokens: jest.Mocked<PublicOnly<Connection['languages']['semanticTokens']>>;
 	};
-	listen: JestFn;
-	onCodeAction: JestFn;
-	onCodeActionResolve: JestFn;
-	onCodeLens: JestFn;
-	onCodeLensResolve: JestFn;
-	onColorPresentation: JestFn;
-	onCompletion: JestFn;
-	onCompletionResolve: JestFn;
-	onDeclaration: JestFn;
-	onDefinition: JestFn;
-	onDidChangeConfiguration: JestFn;
-	onDidChangeTextDocument: JestFn;
-	onDidChangeWatchedFiles: JestFn;
-	onDidCloseTextDocument: JestFn;
-	onDidOpenTextDocument: JestFn;
-	onDidSaveTextDocument: JestFn;
-	onDocumentColor: JestFn;
-	onDocumentFormatting: JestFn;
-	onDocumentHighlight: JestFn;
-	onDocumentLinkResolve: JestFn;
-	onDocumentLinks: JestFn;
-	onDocumentOnTypeFormatting: JestFn;
-	onDocumentRangeFormatting: JestFn;
-	onDocumentSymbol: JestFn;
-	onExecuteCommand: JestFn;
-	onExit: JestFn;
-	onFoldingRanges: JestFn;
-	onHover: JestFn;
-	onImplementation: JestFn;
-	onInitialize: JestFn;
-	onInitialized: JestFn;
-	onNotification: JestFn;
-	onPrepareRename: JestFn;
-	onProgress: JestFn;
-	onReferences: JestFn;
-	onRenameRequest: JestFn;
-	onRequest: JestFn;
-	onSignatureHelp: JestFn;
-	onSelectionRanges: JestFn;
-	onShutdown: JestFn;
-	onTypeDefinition: JestFn;
-	onWillSaveTextDocument: JestFn;
-	onWillSaveTextDocumentWaitUntil: JestFn;
-	onWorkspaceSymbol: JestFn;
-	sendDiagnostics: JestFn;
-	sendNotification: JestFn;
-	sendProgress: JestFn;
-	sendRequest: JestFn;
-	telemetry: {
+	telemetry: jest.Mocked<PublicOnly<Connection['telemetry']>> & {
 		connection: MockConnection;
-		logEvent: JestFn;
 	};
-	tracer: {
+	tracer: jest.Mocked<PublicOnly<Connection['tracer']>> & {
 		connection: MockConnection;
-		log: JestFn;
 	};
-	window: {
+	window: jest.Mocked<PublicOnly<Connection['window']>> & {
 		connection: MockConnection;
-		attachWorkDoneProgress: JestFn;
-		createWorkDoneProgress: JestFn;
-		showDocument: JestFn;
-		showErrorMessage: JestFn;
-		showInformationMessage: JestFn;
-		showWarningMessage: JestFn;
 	};
-	workspace: {
+	workspace: jest.Mocked<PublicOnly<Connection['workspace']>> & {
 		connection: MockConnection;
-		applyEdit: JestFn;
-		getConfiguration: JestFn;
-		getWorkspaceFolders: JestFn;
-		onDidChangeWorkspaceFolders: JestFn;
-		onDidCreateFiles: JestFn;
-		onDidDeleteFiles: JestFn;
-		onDidRenameFiles: JestFn;
-		onWillCreateFiles: JestFn;
-		onWillDeleteFiles: JestFn;
-		onWillRenameFiles: JestFn;
+		getConfiguration: jest.MockInstance<
+			Promise<unknown>,
+			[section: string] | [item: LSP.ConfigurationItem] | [items: LSP.ConfigurationItem[]]
+		>;
 	};
 };
 
@@ -275,7 +241,7 @@ export function getLogger(): jest.Mocked<winston.Logger> {
 		addListener: jest.fn(),
 		alert: jest.fn(),
 		allowHalfOpen: false,
-		child: jest.fn().mockImplementation(() => logger),
+		child: jest.fn(() => logger) as JestFn,
 		close: jest.fn(),
 		clear: jest.fn(),
 		configure: jest.fn(),
@@ -311,19 +277,17 @@ export function getLogger(): jest.Mocked<winston.Logger> {
 		http: jest.fn(),
 		info: jest.fn(),
 		input: jest.fn(),
-		isDebugEnabled: jest.fn().mockReturnValue(true),
-		isErrorEnabled: jest.fn().mockReturnValue(true),
-		isInfoEnabled: jest.fn().mockReturnValue(true),
-		isLevelEnabled: jest
-			.fn()
-			.mockImplementation(
-				(level: string) =>
-					level === 'info' || level === 'error' || level === 'debug' || level === 'warn',
-			),
-		isPaused: jest.fn().mockReturnValue(false),
-		isWarnEnabled: jest.fn().mockReturnValue(true),
-		isSillyEnabled: jest.fn().mockReturnValue(false),
-		isVerboseEnabled: jest.fn().mockReturnValue(false),
+		isDebugEnabled: jest.fn(() => true) as JestFn,
+		isErrorEnabled: jest.fn(() => true) as JestFn,
+		isInfoEnabled: jest.fn(() => true) as JestFn,
+		isLevelEnabled: jest.fn(
+			(level: string) =>
+				level === 'info' || level === 'error' || level === 'debug' || level === 'warn',
+		),
+		isPaused: jest.fn(() => false) as JestFn,
+		isWarnEnabled: jest.fn(() => true) as JestFn,
+		isSillyEnabled: jest.fn(() => false) as JestFn,
+		isVerboseEnabled: jest.fn(() => false) as JestFn,
 		level: 'debug',
 		levels: {
 			silly: 0,
@@ -397,18 +361,8 @@ export function getLogger(): jest.Mocked<winston.Logger> {
 	return logger;
 }
 
-export type MockTextDocuments = {
+export type MockTextDocuments = jest.Mocked<PublicOnly<TextDocuments<TextDocument>>> & {
 	__typed: () => TextDocuments<TextDocument>;
-	all: JestFn;
-	get: JestFn;
-	keys: JestFn;
-	listen: JestFn;
-	onDidChangeContent: JestFn;
-	onDidClose: JestFn;
-	onDidOpen: JestFn;
-	onDidSave: JestFn;
-	onWillSave: JestFn;
-	onWillSaveWaitUntil: JestFn;
 };
 
 /**
@@ -421,21 +375,19 @@ export function getTextDocuments(): MockTextDocuments {
 		get: jest.fn(),
 		keys: jest.fn(),
 		listen: jest.fn(),
-		onDidChangeContent: jest.fn(),
-		onDidClose: jest.fn(),
-		onDidOpen: jest.fn(),
-		onDidSave: jest.fn(),
-		onWillSave: jest.fn(),
-		onWillSaveWaitUntil: jest.fn(),
+		onDidChangeContent: jest.fn(() => ({ dispose: jest.fn() })) as JestFn,
+		onDidClose: jest.fn(() => ({ dispose: jest.fn() })) as JestFn,
+		onDidOpen: jest.fn(() => ({ dispose: jest.fn() })) as JestFn,
+		onDidSave: jest.fn(() => ({ dispose: jest.fn() })) as JestFn,
+		onWillSave: jest.fn(() => ({ dispose: jest.fn() })) as JestFn,
+		onWillSaveWaitUntil: jest.fn(() => ({ dispose: jest.fn() })) as JestFn,
 	};
 
 	return documents;
 }
 
-export type MockCommandManager = {
+export type MockCommandManager = jest.Mocked<PublicOnly<CommandManager>> & {
 	__typed: () => CommandManager;
-	on: JestFn;
-	register: JestFn;
 };
 
 /**
@@ -444,17 +396,32 @@ export type MockCommandManager = {
 export function getCommandManager(): MockCommandManager {
 	const manager: MockCommandManager = {
 		__typed: () => manager as unknown as CommandManager,
-		on: jest.fn(),
+		dispose: jest.fn(),
+		on: jest.fn(() => ({ dispose: jest.fn() })) as JestFn,
 		register: jest.fn(),
 	};
 
 	return manager;
 }
 
-export type MockNotificationManager = {
-	__typed: () => NotificationManager;
-	on: JestFn;
-};
+export type MockNotificationManager = Omit<jest.Mocked<PublicOnly<NotificationManager>>, 'on'> &
+	Pick<NotificationManager, 'on'> & {
+		__typed: () => NotificationManager;
+		on: jest.MockInstance<
+			LSP.Disposable,
+			| [handler: MaybeAsync<LSP.StarNotificationHandler>]
+			| [
+					(
+						| LSP.ProtocolNotificationType0<unknown>
+						| LSP.ProtocolNotificationType<unknown, unknown>
+						| LSP.NotificationType0
+						| LSP.NotificationType<unknown>
+						| string
+					),
+					MaybeAsync<LSP.GenericNotificationHandler>,
+			  ]
+		>;
+	};
 
 /**
  * Returns a mock notification manager.
@@ -462,15 +429,15 @@ export type MockNotificationManager = {
 export function getNotificationManager(): MockNotificationManager {
 	const manager: MockNotificationManager = {
 		__typed: () => manager as unknown as NotificationManager,
-		on: jest.fn(),
+		dispose: jest.fn(),
+		on: jest.fn(() => ({ dispose: jest.fn() })) as JestFn,
 	};
 
 	return manager;
 }
 
-export type MockStylelintRunner = {
+export type MockStylelintRunner = jest.Mocked<PublicOnly<StylelintRunner>> & {
 	__typed: () => StylelintRunner;
-	lintDocument: JestFn;
 };
 
 /**
@@ -510,7 +477,7 @@ export function getOptions(): LanguageServerOptions {
 	};
 }
 
-export type MockLanguageServerContext = {
+export type MockLanguageServerContext = jest.Mocked<PublicOnlyDeep<LanguageServerContext>> & {
 	__typed: () => LanguageServerContext;
 	__options: LanguageServerOptions;
 	connection: MockConnection;
@@ -518,12 +485,6 @@ export type MockLanguageServerContext = {
 	commands: MockCommandManager;
 	notifications: MockNotificationManager;
 	runner: MockStylelintRunner;
-	displayError: JestFn;
-	getOptions: jest.MockedFunction<() => Promise<LanguageServerOptions>>;
-	getFixes: JestFn;
-	getModule: JestFn;
-	lintDocument: JestFn;
-	resolveStylelint: JestFn;
 };
 
 /**
@@ -539,7 +500,7 @@ export function getContext(): MockLanguageServerContext {
 		notifications: getNotificationManager(),
 		runner: getStylelintRunner(),
 		displayError: jest.fn(),
-		getOptions: jest.fn().mockImplementation(async () => context.__options),
+		getOptions: jest.fn(async () => context.__options) as JestFn,
 		getFixes: jest.fn(),
 		getModule: jest.fn(),
 		lintDocument: jest.fn(),