Repository: stylelint/vscode-stylelint
Pull request #326 (2021-11-16 18:46:48)

feat: Allow disposing server, add first LSP test
Handles server shutdown requests and makes the server's internals disposable. Adds the first language server test for #266.

adalinesimonian: Resolved test failures in 26dc41f.

Patch:
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..a2cce63 100644
--- a/src/server/modules/old-stylelint-warning.ts
+++ b/src/server/modules/old-stylelint-warning.ts
@@ -35,11 +35,21 @@ 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;
 	}
@@ -144,39 +154,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 5e398ff..7d7a0b7 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: {
@@ -45,19 +47,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.
@@ -110,12 +124,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;
@@ -148,7 +167,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');
@@ -169,23 +188,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 {
@@ -200,19 +280,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);
 
@@ -220,7 +300,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;
 	}
@@ -229,7 +309,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);
@@ -237,18 +317,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;
 		}
@@ -261,19 +341,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;
 		}
@@ -291,12 +371,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 [];
 		}
@@ -315,28 +395,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');
 	}
 
 	/**
@@ -347,7 +434,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[] };
 
@@ -358,13 +445,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,
 					});
@@ -376,7 +463,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: {
@@ -388,7 +475,7 @@ export class StylelintLanguageServer {
 		};
 
 		if (params.capabilities.workspace?.configuration) {
-			this.#logger?.debug(
+			this.#logger.debug(
 				'Client reports workspace configuration support; using scoped configuration',
 			);
 
@@ -401,16 +488,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',
@@ -419,7 +508,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,
 		});
 
@@ -428,7 +517,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();
 
@@ -439,7 +528,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,
@@ -448,7 +537,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 f1ee25c..c2c1fae 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';
@@ -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/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/server/__tests__/__snapshots__/server.ts.snap b/src/server/__tests__/__snapshots__/server.ts.snap
index 4512e85..58bac66 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..e8a993d 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({});
+		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({});
+		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: {} });
+		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({
+		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({
+		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({
+		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', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.start();
+
+		const onInitializedHandler = mockNotifications.on.mock.calls.find(
+			([type]) => type === InitializedNotification.type,
+		)?.[1];
+
+		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..0c6a6a9 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();
@@ -49,7 +50,7 @@ describe('CodeActionModule', () => {
 
 		module.onDidRegisterHandlers();
 
-		mockContext.notifications.on.mock.calls[0][1]();
+		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 7addde7..8ca6760 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';
@@ -77,12 +79,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 });
 
@@ -90,32 +89,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 () => {
@@ -125,7 +115,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();
@@ -141,10 +138,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();
@@ -152,10 +153,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 });
@@ -164,10 +162,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();
@@ -181,10 +183,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);
 
@@ -194,12 +193,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();
@@ -226,7 +229,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();
 	});
@@ -248,7 +251,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();
 	});
@@ -270,7 +273,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();
 	});
@@ -292,8 +295,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,
@@ -323,7 +330,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(
@@ -349,7 +356,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,
@@ -377,11 +386,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', {
@@ -409,11 +420,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));
 
@@ -441,7 +454,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',
@@ -470,14 +485,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);
 	});
@@ -504,14 +523,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));
 
@@ -547,15 +570,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);
 	});
@@ -582,15 +609,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));
 
@@ -619,11 +650,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);
 	});
@@ -645,7 +678,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,
@@ -657,4 +692,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..3c87804 100644
--- a/src/server/modules/__tests__/old-stylelint-warning.ts
+++ b/src/server/modules/__tests__/old-stylelint-warning.ts
@@ -5,7 +5,9 @@ jest.mock('path');
 
 import fs from 'fs/promises';
 import path from 'path';
+import type stylelint from 'stylelint';
 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';
@@ -55,7 +57,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).not.toHaveBeenCalled();
@@ -82,7 +84,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).not.toHaveBeenCalled();
@@ -106,15 +108,11 @@ 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: { uri: 'foo', languageId: 'bar' },
-		});
+		await handler({ document });
+		await handler({ document });
 
 		expect(mockContext.resolveStylelint).toHaveBeenCalledTimes(1);
 		expect(mockContext.connection.window.showWarningMessage).not.toHaveBeenCalled();
@@ -128,7 +126,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue(undefined);
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as stylelint.PublicApi,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -145,7 +143,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);
@@ -161,7 +159,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as stylelint.PublicApi,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -179,7 +177,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 +192,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as stylelint.PublicApi,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -212,7 +210,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 +229,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as stylelint.PublicApi,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -249,7 +247,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 +258,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as stylelint.PublicApi,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -278,7 +276,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 +292,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as stylelint.PublicApi,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -312,7 +310,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 +321,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as stylelint.PublicApi,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -341,7 +339,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 +350,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as stylelint.PublicApi,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -370,7 +368,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 +381,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as stylelint.PublicApi,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -408,7 +406,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 +418,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as stylelint.PublicApi,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -450,7 +448,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 +462,7 @@ describe('OldStylelintWarningModule', () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
 		mockedFindPackageRoot.mockResolvedValue('/path/node_modules/stylelint');
 		mockContext.resolveStylelint.mockResolvedValue({
-			stylelint: {},
+			stylelint: {} as unknown as stylelint.PublicApi,
 			resolvedPath: '/path/node_modules/stylelint',
 		});
 		mockContext.__options.validate = ['bar'];
@@ -494,7 +492,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 +500,28 @@ describe('OldStylelintWarningModule', () => {
 		expect(mockContext.connection.window.showDocument).toHaveBeenCalledTimes(1);
 		expect(mockLogger.warn).toHaveBeenCalledWith('Failed to open migration guide');
 	});
+
+	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/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/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 6683788..3021c4b 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;
 };
 
 /**
@@ -509,7 +476,7 @@ export function getOptions(): LanguageServerOptions {
 	};
 }
 
-export type MockLanguageServerContext = {
+export type MockLanguageServerContext = jest.Mocked<PublicOnlyDeep<LanguageServerContext>> & {
 	__typed: () => LanguageServerContext;
 	__options: LanguageServerOptions;
 	connection: MockConnection;
@@ -517,12 +484,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;
 };
 
 /**
@@ -538,7 +499,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(),