Repository: stylelint/vscode-stylelint
Pull request #318 (2021-11-07 03:25:50)

feat: resource-scoped configuration support
This is a major feature. With this support, we now support multiple workspaces opened at once in VS Code, which we've never done before. Using this, we should be also able to _**massively**_ speed up e2e tests.

If the client supports the `workspace/configuration` request, we use it to pull extension settings per resource (e.g. document). If it doesn't, we fall back to using a global configuration.

- Added `getOptions(uri)` to `LanguageServerContext`. Returns extension options specific to the given URI. If resource-scoped configuration isn't supported by the client, returns the global options regardless of URI.
- `FormatterModule` now registers formatters per document instead of per language ID.
- The `DidRegisterDocumentFormattingEditProvider` language server notification and its corresponding extension API event now provide the URI and registration options for each registration. This is used by the formatter test to wait for the specific registration that applies to the document fixture.
- `formattingReady` flag removed from extension API.
- `options` removed from `LanguageServerContext`.
- `onDidChangeValidateLanguages` language server event removed.

adalinesimonian: Unit tests haven't been updated just yet, so linting and testing will not work for this PR on CI for the time being. However, end-to-end tests pass if you run them yourself, and I've manually validated functionality in a single workspace and in a multi-root workspace.
adalinesimonian: End-to-end tests have always been sluggish for this project. **Not anymore!**

Here's the test results on my machine:

```
Test Suites: 14 passed, 14 total
Tests:       17 passed, 17 total
Snapshots:   17 passed, 17 total
Time:        20.459 s
```

That's right — just over **20 seconds** for **ALL** of the end-to-end tests!

And with that, I'm calling it a night. Can't wait to see what it'll be like on CI once I get the unit tests working.

Patch:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 974dde8..86a7a54 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -9,7 +9,7 @@
 			"runtimeExecutable": "${execPath}",
 			"args": [
 				"--extensionDevelopmentPath=${workspaceRoot}",
-				"${workspaceRoot}/fixture.css",
+				"${workspaceRoot}/test/e2e/workspace/workspace.code-workspace",
 				"--trace-warnings"
 			],
 			"env": { "NODE_ENV": "development" },
diff --git a/fixture.css b/fixture.css
deleted file mode 100644
index e69de29..0000000
diff --git a/package.json b/package.json
index 23cc93f..2ef2fb1 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
   "name": "vscode-stylelint",
   "version": "1.2.0",
   "license": "MIT",
-  "description": "Modern CSS/Less/SCSS linter",
+  "description": "Official Stylelint extension for Visual Studio Code",
   "main": "dist/index.js",
   "displayName": "Stylelint",
   "publisher": "stylelint",
@@ -49,11 +49,13 @@
       "title": "Stylelint",
       "properties": {
         "stylelint.enable": {
+          "scope": "resource",
           "type": "boolean",
           "default": true,
           "description": "Control whether Stylelint is enabled or not."
         },
         "stylelint.config": {
+          "scope": "resource",
           "type": [
             "object",
             "null"
@@ -62,36 +64,43 @@
           "description": "A Stylelint configuration object."
         },
         "stylelint.configFile": {
+          "scope": "resource",
           "type": "string",
           "default": "",
           "description": "Path to a JSON, YAML, or JS file that contains your configuration object. Use this option if you don't want Stylelint to search for a configuration file."
         },
         "stylelint.configBasedir": {
+          "scope": "resource",
           "type": "string",
           "default": "",
           "description": "A path to the directory that relative paths defining \"extends\" and \"plugins\" are relative to."
         },
         "stylelint.customSyntax": {
+          "scope": "resource",
           "type": "string",
           "default": "",
           "description": "An absolute path to a custom PostCSS-compatible syntax module."
         },
         "stylelint.ignoreDisables": {
+          "scope": "resource",
           "type": "boolean",
           "default": false,
           "description": "Ignore `stylelint-disable` (e.g. `/* stylelint-disable block-no-empty */`) comments"
         },
         "stylelint.reportNeedlessDisables": {
+          "scope": "resource",
           "type": "boolean",
           "default": false,
           "description": "Also report errors for `stylelint-disable` comments that are not blocking a lint warning."
         },
         "stylelint.reportInvalidScopeDisables": {
+          "scope": "resource",
           "type": "boolean",
           "default": false,
           "description": "Also report errors for `stylelint-disable` comments that used for rules that don't exist within the configuration object."
         },
         "stylelint.stylelintPath": {
+          "scope": "resource",
           "type": "string",
           "default": "",
           "description": "Supply a custom path to the Stylelint module."
diff --git a/scripts/download-vscode.ts b/scripts/download-vscode.ts
index ecb4d9b..cf7f94e 100644
--- a/scripts/download-vscode.ts
+++ b/scripts/download-vscode.ts
@@ -1,4 +1,4 @@
 import { downloadAndUnzipVSCode } from '@vscode/test-electron';
-import { version, platform } from '../jest-runner-vscode.config';
+import { version, platform } from '../test/e2e/jest-runner-vscode.config';
 
 downloadAndUnzipVSCode(version, platform);
diff --git a/src/extension/extension.ts b/src/extension/extension.ts
index 8ad7356..b3e2e26 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -2,7 +2,11 @@ import { EventEmitter } from 'events';
 import { LanguageClient, SettingMonitor, ExecuteCommandRequest } from 'vscode-languageclient/node';
 import { workspace, commands, window } from 'vscode';
 import { ApiEvent, PublicApi } from './types';
-import { CommandId, Notification } from '../server';
+import {
+	CommandId,
+	DidRegisterDocumentFormattingEditProviderNotificationParams,
+	Notification,
+} from '../server';
 import type vscode from 'vscode';
 
 /**
@@ -11,9 +15,7 @@ import type vscode from 'vscode';
 export function activate({ subscriptions }: vscode.ExtensionContext): PublicApi {
 	const serverPath = require.resolve('./start-server');
 
-	const api: PublicApi = Object.assign(new EventEmitter(), {
-		formattingReady: false,
-	});
+	const api: PublicApi = new EventEmitter();
 
 	const client = new LanguageClient(
 		'Stylelint',
@@ -41,10 +43,12 @@ export function activate({ subscriptions }: vscode.ExtensionContext): PublicApi
 	);
 
 	client.onReady().then(() => {
-		client.onNotification(Notification.DidRegisterDocumentFormattingEditProvider, () => {
-			api.formattingReady = true;
-			api.emit(ApiEvent.DidRegisterDocumentFormattingEditProvider);
-		});
+		client.onNotification(
+			Notification.DidRegisterDocumentFormattingEditProvider,
+			(params: DidRegisterDocumentFormattingEditProviderNotificationParams) => {
+				api.emit(ApiEvent.DidRegisterDocumentFormattingEditProvider, params);
+			},
+		);
 	});
 
 	subscriptions.push(
diff --git a/src/extension/types.ts b/src/extension/types.ts
index 7478b14..da2dcf2 100644
--- a/src/extension/types.ts
+++ b/src/extension/types.ts
@@ -1,19 +1,20 @@
 // eslint-disable-next-line node/no-unpublished-import
 import type TypedEventEmitter from 'typed-emitter';
+import type { DidRegisterDocumentFormattingEditProviderNotificationParams } from '../server';
 
 /**
  * VS Code extension event names.
  */
 export interface ExtensionEvents {
-	DidRegisterDocumentFormattingEditProvider: () => void;
+	DidRegisterDocumentFormattingEditProvider: (
+		params: DidRegisterDocumentFormattingEditProviderNotificationParams,
+	) => void;
 }
 
 /**
  * VS Code extension public API.
  */
-export type PublicApi = TypedEventEmitter<ExtensionEvents> & {
-	formattingReady: boolean;
-};
+export type PublicApi = TypedEventEmitter<ExtensionEvents>;
 
 /**
  * Extension API event names.
diff --git a/src/server/modules/auto-fix.ts b/src/server/modules/auto-fix.ts
index 0acec77..39716a2 100644
--- a/src/server/modules/auto-fix.ts
+++ b/src/server/modules/auto-fix.ts
@@ -27,8 +27,10 @@ export class AutoFixModule implements LanguageServerModule {
 		this.#logger = logger;
 	}
 
-	#shouldAutoFix(document: TextDocument): boolean {
-		return this.#context.options.validate.includes(document.languageId);
+	async #shouldAutoFix(document: TextDocument): Promise<boolean> {
+		const options = await this.#context.getOptions(document.uri);
+
+		return options.validate.includes(document.languageId);
 	}
 
 	onInitialize(): Partial<LSP.InitializeResult> {
@@ -55,7 +57,7 @@ export class AutoFixModule implements LanguageServerModule {
 			const uri = identifier.uri;
 			const document = this.#context.documents.get(uri);
 
-			if (!document || !this.#shouldAutoFix(document)) {
+			if (!document || !(await this.#shouldAutoFix(document))) {
 				if (this.#logger?.isDebugEnabled()) {
 					if (!document) {
 						this.#logger.debug('Unknown document, ignoring', { uri });
diff --git a/src/server/modules/code-action.ts b/src/server/modules/code-action.ts
index fb9905d..05ad2bc 100644
--- a/src/server/modules/code-action.ts
+++ b/src/server/modules/code-action.ts
@@ -27,8 +27,10 @@ export class CodeActionModule implements LanguageServerModule {
 		this.#logger = logger;
 	}
 
-	#shouldCodeAction(document: TextDocument): boolean {
-		return this.#context.options.validate.includes(document.languageId);
+	async #shouldCodeAction(document: TextDocument): Promise<boolean> {
+		const options = await this.#context.getOptions(document.uri);
+
+		return options.validate.includes(document.languageId);
 	}
 
 	onInitialize(): Partial<LSP.InitializeResult> {
@@ -62,7 +64,7 @@ export class CodeActionModule implements LanguageServerModule {
 			const uri = textDocument.uri;
 			const document = this.#context.documents.get(uri);
 
-			if (!document || !this.#shouldCodeAction(document)) {
+			if (!document || !(await this.#shouldCodeAction(document))) {
 				if (this.#logger?.isDebugEnabled()) {
 					if (!document) {
 						this.#logger.debug('Unknown document, ignoring', { uri });
diff --git a/src/server/modules/completion.ts b/src/server/modules/completion.ts
index 4738f24..f85263d 100644
--- a/src/server/modules/completion.ts
+++ b/src/server/modules/completion.ts
@@ -46,21 +46,26 @@ export class CompletionModule implements LanguageServerModule {
 		this.#logger?.debug('onCompletion handler registered');
 	}
 
-	#shouldComplete(document: TextDocument): boolean {
+	async #shouldComplete(document: TextDocument): Promise<boolean> {
+		const options = await this.#context.getOptions(document.uri);
+
 		return (
-			this.#context.options.validate.includes(document.languageId) &&
-			this.#context.options.snippet.includes(document.languageId)
+			options.validate.includes(document.languageId) &&
+			options.snippet.includes(document.languageId)
 		);
 	}
 
-	#onCompletion({ textDocument, position }: LSP.CompletionParams): LSP.CompletionItem[] {
+	async #onCompletion({
+		textDocument,
+		position,
+	}: LSP.CompletionParams): Promise<LSP.CompletionItem[]> {
 		const { uri } = textDocument;
 
 		this.#logger?.debug('Received onCompletion', { uri, position });
 
 		const document = this.#context.documents.get(uri);
 
-		if (!document || !this.#shouldComplete(document)) {
+		if (!document || !(await this.#shouldComplete(document))) {
 			if (this.#logger?.isDebugEnabled()) {
 				if (!document) {
 					this.#logger.debug('Unknown document, ignoring', { uri });
diff --git a/src/server/modules/formatter.ts b/src/server/modules/formatter.ts
index 5f13357..6915792 100644
--- a/src/server/modules/formatter.ts
+++ b/src/server/modules/formatter.ts
@@ -1,15 +1,14 @@
-import { DocumentFormattingRequest } from 'vscode-languageserver-protocol';
 import type { TextDocument } from 'vscode-languageserver-textdocument';
-import type LSP from 'vscode-languageserver-protocol';
+import * as LSP from 'vscode-languageserver-protocol';
 import { formattingOptionsToRules } from '../../utils/stylelint';
 import { Notification } from '../types';
 import type {
-	DidChangeValidateLanguagesParams,
 	LanguageServerContext,
 	LanguageServerModuleConstructorParameters,
 	LanguageServerModule,
 } from '../types';
 import type winston from 'winston';
+import { URI } from 'vscode-uri';
 
 export class FormatterModule implements LanguageServerModule {
 	static id = 'formatter';
@@ -31,17 +30,20 @@ export class FormatterModule implements LanguageServerModule {
 	#registerDynamically = false;
 
 	/**
-	 * The disposable for the dynamically registered document formatter.
+	 * Promises that resolve to the disposables for the dynamically registered
+	 * document formatters, by resource URI.
 	 */
-	#registration: LSP.Disposable | undefined = undefined;
+	#registrations = new Map<string, Promise<LSP.Disposable>>();
 
 	constructor({ context, logger }: LanguageServerModuleConstructorParameters) {
 		this.#context = context;
 		this.#logger = logger;
 	}
 
-	#shouldFormat(document: TextDocument): boolean {
-		return this.#context.options.validate.includes(document.languageId);
+	async #shouldFormat(document: TextDocument): Promise<boolean> {
+		const options = await this.#context.getOptions(document.uri);
+
+		return options.validate.includes(document.languageId);
 	}
 
 	onInitialize({ capabilities }: LSP.InitializeParams): Partial<LSP.InitializeResult> {
@@ -56,10 +58,64 @@ export class FormatterModule implements LanguageServerModule {
 		};
 	}
 
-	onDidRegisterHandlers(): void {
-		this.#logger?.debug('Registering onDocumentFormatting handler');
+	async #register(document: TextDocument): Promise<void> {
+		if (
+			!this.#registerDynamically ||
+			!(await this.#shouldFormat(document)) ||
+			this.#registrations.has(document.uri)
+		) {
+			return;
+		}
+
+		const { scheme, fsPath } = URI.parse(document.uri);
+
+		const pattern = (scheme === 'file' ? fsPath.replace(/\\/g, '/') : fsPath).replace(
+			/[[\]{}]/g,
+			'?',
+		);
+
+		const filter: LSP.DocumentFilter = { scheme, pattern };
+		const options: LSP.DocumentFormattingRegistrationOptions = { documentSelector: [filter] };
+
+		this.#registrations.set(
+			document.uri,
+			this.#context.connection.client.register(LSP.DocumentFormattingRequest.type, options),
+		);
+
+		this.#logger?.debug('Registering formatter for document', { uri: document.uri, options });
+
+		this.#context.connection.sendNotification(
+			Notification.DidRegisterDocumentFormattingEditProvider,
+			{ uri: document.uri, options },
+		);
+	}
+
+	#deregister(uri: string): void {
+		const registration = this.#registrations.get(uri);
+
+		if (!registration) {
+			return;
+		}
+
+		this.#logger?.debug('Deregistering formatter for document', { uri });
+
+		registration.then(({ dispose }) => dispose());
+		this.#registrations.delete(uri);
+	}
+
+	#deregisterAll(): void {
+		for (const [uri, registration] of this.#registrations) {
+			this.#logger?.debug('Deregistering formatter for document', { uri });
+
+			registration.then(({ dispose }) => dispose());
+		}
+
+		this.#registrations.clear();
+	}
 
-		this.#context.connection.onDocumentFormatting(({ textDocument, options }) => {
+	onDidRegisterHandlers(): void {
+		this.#logger?.debug('Registering connection.onDocumentFormatting handler');
+		this.#context.connection.onDocumentFormatting(async ({ textDocument, options }) => {
 			this.#logger?.debug('Received onDocumentFormatting', { textDocument, options });
 
 			if (!textDocument) {
@@ -71,7 +127,7 @@ export class FormatterModule implements LanguageServerModule {
 			const { uri } = textDocument;
 			const document = this.#context.documents.get(uri);
 
-			if (!document || !this.#shouldFormat(document)) {
+			if (!document || !(await this.#shouldFormat(document))) {
 				if (this.#logger?.isDebugEnabled()) {
 					if (!document) {
 						this.#logger.debug('Unknown document, ignoring', { uri });
@@ -100,54 +156,34 @@ export class FormatterModule implements LanguageServerModule {
 
 			return fixes;
 		});
-
-		this.#logger?.debug('onDocumentFormatting handler registered');
-	}
-
-	async onDidChangeValidateLanguages({
-		languages,
-	}: DidChangeValidateLanguagesParams): Promise<void> {
-		if (this.#logger?.isDebugEnabled()) {
-			this.#logger?.debug('Received onDidChangeValidateLanguages', { languages: [...languages] });
-		}
-
-		// If dynamic registration is supported and the list of languages that should be validated
-		// has changed, then (re-)register the formatter.
-		if (this.#registerDynamically) {
-			// Dispose the old formatter registration if it exists.
-			if (this.#registration) {
-				this.#logger?.debug('Disposing old formatter registration');
-
-				this.#registration.dispose();
-
-				this.#logger?.debug('Old formatter registration disposed');
-			}
-
-			// If there are languages that should be validated, register a formatter for those
-			// languages.
-			if (languages.size > 0) {
-				const documentSelector = [];
-
-				for (const language of languages) {
-					documentSelector.push({ language });
-				}
-
-				if (this.#logger?.isDebugEnabled()) {
-					this.#logger?.debug('Registering formatter for languages', { languages: [...languages] });
-				}
-
-				this.#registration = await this.#context.connection.client.register(
-					DocumentFormattingRequest.type,
-					{ documentSelector },
-				);
-
-				this.#context.connection.sendNotification(
-					Notification.DidRegisterDocumentFormattingEditProvider,
-					{},
-				);
-
-				this.#logger?.debug('Formatter registered');
-			}
-		}
+		this.#logger?.debug('connection.onDocumentFormatting handler registered');
+
+		this.#logger?.debug('Registering documents.onDidOpen handler');
+		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.#logger?.debug('documents.onDidChangeContent handler registered');
+
+		this.#logger?.debug('Registering documents.onDidSave handler');
+		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.#logger?.debug('documents.onDidClose handler registered');
+
+		this.#logger?.debug('Registering DidChangeConfigurationNotification');
+		this.#context.connection.onNotification(LSP.DidChangeConfigurationNotification.type, () =>
+			this.#deregisterAll(),
+		);
+		this.#logger?.debug('DidChangeConfigurationNotification registered');
+
+		this.#logger?.debug('Registering DidChangeWorkspaceFoldersNotification');
+		this.#context.connection.onNotification(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 32c7210..31acaa4 100644
--- a/src/server/modules/old-stylelint-warning.ts
+++ b/src/server/modules/old-stylelint-warning.ts
@@ -85,7 +85,9 @@ export class OldStylelintWarningModule implements LanguageServerModule {
 	}
 
 	async #check(document: TextDocument): Promise<string | undefined> {
-		if (!this.#context.options.validate.includes(document.languageId)) {
+		const options = await this.#context.getOptions(document.uri);
+
+		if (!options.validate.includes(document.languageId)) {
 			this.#logger?.debug('Document should not be validated, ignoring', {
 				uri: document.uri,
 				language: document.languageId,
diff --git a/src/server/modules/validator.ts b/src/server/modules/validator.ts
index e6ce174..4cd0694 100644
--- a/src/server/modules/validator.ts
+++ b/src/server/modules/validator.ts
@@ -2,7 +2,6 @@ import type winston from 'winston';
 import type { TextDocument } from 'vscode-languageserver-textdocument';
 import type LSP from 'vscode-languageserver-protocol';
 import type {
-	DidChangeValidateLanguagesParams,
 	LanguageServerContext,
 	LanguageServerModuleConstructorParameters,
 	LanguageServerModule,
@@ -31,16 +30,26 @@ export class ValidatorModule implements LanguageServerModule {
 		this.#logger = logger;
 	}
 
-	#shouldValidate(document: TextDocument): boolean {
-		return this.#context.options.validate.includes(document.languageId);
+	async #shouldValidate(document: TextDocument): Promise<boolean> {
+		const options = await this.#context.getOptions(document.uri);
+
+		return options.validate.includes(document.languageId);
 	}
 
 	async #validate(document: TextDocument): Promise<void> {
-		if (!this.#shouldValidate(document)) {
-			this.#logger?.debug('Document should not be validated, ignoring', {
-				uri: document.uri,
-				language: document.languageId,
-			});
+		if (!(await this.#shouldValidate(document))) {
+			if (this.#documentDiagnostics.has(document.uri)) {
+				this.#logger?.debug('Document should not be validated, clearing diagnostics', {
+					uri: document.uri,
+					language: document.languageId,
+				});
+				this.#clearDiagnostics(document);
+			} else {
+				this.#logger?.debug('Document should not be validated, ignoring', {
+					uri: document.uri,
+					language: document.languageId,
+				});
+			}
 
 			return;
 		}
@@ -119,18 +128,4 @@ export class ValidatorModule implements LanguageServerModule {
 
 		await this.#validateAll();
 	}
-
-	onDidChangeValidateLanguages({ removedLanguages }: DidChangeValidateLanguagesParams): void {
-		if (this.#logger?.isDebugEnabled()) {
-			this.#logger?.debug('Received onDidChangeValidateLanguages', {
-				removedLanguages: [...removedLanguages],
-			});
-		}
-
-		for (const document of this.#context.documents.all()) {
-			if (removedLanguages.has(document.languageId)) {
-				this.#clearDiagnostics(document);
-			}
-		}
-	}
 }
diff --git a/src/server/server.ts b/src/server/server.ts
index 18a7be5..da41c4c 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -1,8 +1,11 @@
 import { TextDocument, TextEdit } from 'vscode-languageserver-textdocument';
-import type { Connection } from 'vscode-languageserver';
+import type { Connection, TextDocumentChangeEvent } from 'vscode-languageserver';
 import type LSP from 'vscode-languageserver-protocol';
 import { TextDocuments } from 'vscode-languageserver/node';
-import { TextDocumentSyncKind } from 'vscode-languageserver-protocol';
+import {
+	DidChangeConfigurationNotification,
+	TextDocumentSyncKind,
+} from 'vscode-languageserver-protocol';
 // eslint-disable-next-line node/no-unpublished-import
 import type stylelint from 'stylelint';
 import type winston from 'winston';
@@ -42,9 +45,10 @@ export class StylelintLanguageServer {
 	#logger: winston.Logger | undefined;
 
 	/**
-	 * The language server options.
+	 * The global language server options, used if the client does not support
+	 * the `workspace/configuration` request.
 	 */
-	#options: LanguageServerOptions;
+	#globalOptions: LanguageServerOptions;
 
 	/**
 	 * The resolver used to resolve the Stylelint package.
@@ -72,27 +76,30 @@ export class StylelintLanguageServer {
 	#modules: Map<string, LanguageServerModule> = new Map();
 
 	/**
-	 * Whether or not the server has sent its first configuration change to
-	 * modules.
+	 * Whether or not the client supports the `workspace/configuration` request.
 	 */
-	#hasSentInitialConfiguration = false;
+	#hasConfigurationCapability = false;
+
+	/**
+	 * Configuration per resource.
+	 */
+	#scopedOptions = new Map<string, LanguageServerOptions>();
 
 	/**
 	 * Creates a new Stylelint language server.
-	 * @param {LanguageServerConstructorParameters} params
 	 */
 	constructor({ connection, logger, modules }: LanguageServerConstructorParameters) {
 		this.#connection = connection;
 		this.#logger = logger?.child({ component: 'language-server' });
-		this.#options = defaultOptions;
+		this.#globalOptions = defaultOptions;
 		this.#resolver = new StylelintResolver(connection, this.#logger);
 		this.#runner = new StylelintRunner(connection, this.#logger, this.#resolver);
 		this.#documents = new TextDocuments(TextDocument);
 		this.#context = {
 			connection: this.#connection,
 			documents: this.#documents,
-			options: this.#options,
 			runner: this.#runner,
+			getOptions: this.#getOptions.bind(this),
 			getModule: this.#getModule.bind(this),
 			getFixes: this.#getFixes.bind(this),
 			displayError: this.#displayError.bind(this),
@@ -144,11 +151,11 @@ export class StylelintLanguageServer {
 	start(): void {
 		this.#logger?.info('Starting language server');
 
-		this.#registerHandlers();
-
 		this.#documents.listen(this.#connection);
 		this.#connection.listen();
 
+		this.#registerHandlers();
+
 		this.#logger?.info('Language server started');
 	}
 
@@ -156,6 +163,31 @@ export class StylelintLanguageServer {
 		displayError(this.#connection, error);
 	}
 
+	async #getOptions(resource: string): Promise<LanguageServerOptions> {
+		if (!this.#hasConfigurationCapability) {
+			return this.#globalOptions;
+		}
+
+		const cached = this.#scopedOptions.get(resource);
+
+		if (cached) {
+			return cached;
+		}
+
+		const options = await this.#connection.workspace.getConfiguration({
+			scopeUri: resource,
+			section: 'stylelint',
+		});
+
+		const withDefaults = this.#getEffectiveOptions(options);
+
+		Object.freeze(withDefaults);
+
+		this.#scopedOptions.set(resource, withDefaults);
+
+		return withDefaults;
+	}
+
 	/**
 	 * Resolves the Stylelint package for the given document.
 	 */
@@ -163,7 +195,9 @@ export class StylelintLanguageServer {
 		this.#logger?.debug('Resolving Stylelint', { uri: document.uri });
 
 		try {
-			const result = await this.#resolver.resolve(this.#options, document);
+			const options = await this.#getOptions(document.uri);
+
+			const result = await this.#resolver.resolve(options, document);
 
 			if (result) {
 				this.#logger?.debug('Stylelint resolved', {
@@ -193,7 +227,9 @@ export class StylelintLanguageServer {
 		this.#logger?.debug('Linting document', { uri: document.uri, linterOptions });
 
 		try {
-			const results = await this.#runner.lintDocument(document, linterOptions, this.#options);
+			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 });
 
@@ -214,7 +250,9 @@ export class StylelintLanguageServer {
 		linterOptions: stylelint.LinterOptions = {},
 	): Promise<TextEdit[]> {
 		try {
-			const edits = await getFixes(this.#runner, document, linterOptions, this.#options);
+			const options = await this.#getOptions(document.uri);
+
+			const edits = await getFixes(this.#runner, document, linterOptions, options);
 
 			this.#logger?.debug('Fixes retrieved', { uri: document.uri, edits });
 
@@ -235,10 +273,11 @@ export class StylelintLanguageServer {
 	}
 
 	/**
-	 * Sets the language server options.
+	 * Returns a shallow copy of the given options with defaults set for options
+	 * that are not set. If no options are given, the default options are returned.
 	 */
-	#setOptions(options: Partial<LanguageServerOptions>): void {
-		this.#options = {
+	#getEffectiveOptions(options: Partial<LanguageServerOptions> = {}): LanguageServerOptions {
+		const withDefaults = {
 			config: options.config,
 			configBasedir: options.configBasedir,
 			configFile: options.configFile,
@@ -252,11 +291,7 @@ export class StylelintLanguageServer {
 			validate: options.validate ?? defaultOptions.validate,
 		};
 
-		Object.freeze(this.#options);
-
-		this.#context.options = this.#options;
-
-		this.#logger?.debug('Options updated', { options: this.#options });
+		return withDefaults;
 	}
 
 	/**
@@ -268,12 +303,16 @@ export class StylelintLanguageServer {
 		this.#logger?.info('Registering handlers');
 
 		this.#connection.onInitialize(this.#onInitialize.bind(this));
+		this.#logger?.debug('connection.onInitialize handler registered');
 
-		this.#logger?.debug('onInitialize handler registered');
+		this.#connection.onInitialized(this.#onInitialized.bind(this));
+		this.#logger?.debug('connection.onInitialized handler registered');
 
 		this.#connection.onDidChangeConfiguration(this.#onDidChangeConfiguration.bind(this));
+		this.#logger?.debug('connection.onDidChangeConfiguration handler registered');
 
-		this.#logger?.debug('onDidChangeConfiguration handler registered');
+		this.#documents.onDidClose(this.#onDidCloseDocument.bind(this));
+		this.#logger?.debug('documents.onDidClose handler registered');
 
 		this.#invokeHandlers('onDidRegisterHandlers');
 
@@ -328,6 +367,14 @@ export class StylelintLanguageServer {
 			},
 		};
 
+		if (params.capabilities.workspace?.configuration) {
+			this.#logger?.debug(
+				'Client reports workspace configuration support; using scoped configuration',
+			);
+
+			this.#hasConfigurationCapability = true;
+		}
+
 		for (const [, moduleResult] of Object.entries(this.#invokeHandlers('onInitialize', params))) {
 			if (moduleResult) {
 				deepAssign(result, moduleResult);
@@ -339,47 +386,43 @@ export class StylelintLanguageServer {
 		return result;
 	}
 
-	#onDidChangeConfiguration(params: LSP.DidChangeConfigurationParams): void {
-		this.#logger?.debug('received onDidChangeConfiguration', { params });
+	#onInitialized(params: LSP.InitializedParams): void {
+		this.#logger?.debug('received onInitialized', { params });
 
-		const oldOptions = this.#options;
+		if (this.#hasConfigurationCapability) {
+			this.#logger?.debug('Registering DidChangeConfigurationNotification');
 
-		this.#setOptions(params.settings.stylelint);
+			this.#connection.client.register(DidChangeConfigurationNotification.type);
+		}
+	}
 
-		const validateLanguageSet = new Set(this.#options.validate);
-		const oldValidateLanguageSet = new Set(oldOptions.validate);
+	#onDidCloseDocument({ document }: TextDocumentChangeEvent<TextDocument>): void {
+		this.#logger?.debug('received documents.onDidClose, clearing cached options', {
+			uri: document.uri,
+		});
 
-		/** Whether or not the list of languages that should be validated has changed. */
-		let changed = validateLanguageSet.size !== oldValidateLanguageSet.size;
+		this.#scopedOptions.delete(document.uri);
+	}
 
-		/** The languages removed from the list of languages that should be validated */
-		const removedLanguages = new Set();
+	#onDidChangeConfiguration(params: LSP.DidChangeConfigurationParams): void {
+		if (this.#hasConfigurationCapability) {
+			this.#logger?.debug('received onDidChangeConfiguration, clearing cached options', { params });
 
-		// Check if the sets are unequal, which means that the list of languages that should be
-		// validated has changed.
-		for (const language of oldValidateLanguageSet) {
-			if (!validateLanguageSet.has(language)) {
-				removedLanguages.add(language);
-				changed = true;
-			}
-		}
+			this.#scopedOptions.clear();
 
-		if (changed || !this.#hasSentInitialConfiguration) {
-			if (this.#logger?.isDebugEnabled()) {
-				this.#logger?.debug('Languages that should be validated changed', {
-					languages: [...validateLanguageSet],
-					removedLanguages: [...removedLanguages],
-				});
-			}
+			this.#invokeHandlers('onDidChangeConfiguration');
 
-			this.#invokeHandlers('onDidChangeValidateLanguages', {
-				languages: validateLanguageSet,
-				removedLanguages,
-			});
+			return;
 		}
 
-		this.#invokeHandlers('onDidChangeConfiguration', { settings: this.#options });
+		this.#logger?.debug('received onDidChangeConfiguration', { params });
+
+		this.#globalOptions = this.#getEffectiveOptions(params.settings.stylelint);
+
+		Object.freeze(this.#globalOptions);
+
+		this.#logger?.debug('Global options updated', { options: this.#globalOptions });
 
-		this.#hasSentInitialConfiguration = true;
+		this.#invokeHandlers('onDidChangeConfiguration');
 	}
 }
diff --git a/src/server/types.ts b/src/server/types.ts
index 84129f6..263d467 100644
--- a/src/server/types.ts
+++ b/src/server/types.ts
@@ -31,6 +31,21 @@ export enum Notification {
 	DidRegisterDocumentFormattingEditProvider = 'textDocument/didRegisterDocumentFormattingEditProvider',
 }
 
+/**
+ * `DidRegisterDocumentFormattingEditProvider` notification parameters.
+ */
+export interface DidRegisterDocumentFormattingEditProviderNotificationParams {
+	/**
+	 * The URI of the document for which the formatting provider was registered.
+	 */
+	readonly uri: string;
+
+	/**
+	 * The options used to register the document formatting provider.
+	 */
+	readonly options: LSP.DocumentFormattingRegistrationOptions;
+}
+
 /**
  * Context shared between the language server and its modules.
  */
@@ -45,11 +60,6 @@ export interface LanguageServerContext {
 	 */
 	documents: TextDocuments<TextDocument>;
 
-	/**
-	 * The current language server options.
-	 */
-	options: LanguageServerOptions;
-
 	/**
 	 * The runner with which to run Stylelint.
 	 */
@@ -62,6 +72,12 @@ export interface LanguageServerContext {
 	 */
 	displayError(error: unknown): void;
 
+	/**
+	 * Gets the effective extension options for a resource, given its URI.
+	 * @param uri The resource URI.
+	 */
+	getOptions(uri: string): Promise<LanguageServerOptions>;
+
 	/**
 	 * Returns the module with the given ID if it exists.
 	 * @param id The ID of the module to return.
@@ -94,33 +110,6 @@ export interface LanguageServerContext {
 	resolveStylelint(document: TextDocument): Promise<StylelintResolutionResult | undefined>;
 }
 
-/**
- * Parameters for the {@link LanguageServerModule.onDidChangeValidateLanguages}
- * event.
- */
-export interface DidChangeValidateLanguagesParams {
-	/**
-	 * IDs for the languages that should be validated.
-	 */
-	languages: Set<string>;
-
-	/**
-	 * IDs for the languages that should no longer be validated.
-	 */
-	removedLanguages: Set<string>;
-}
-
-/**
- * Parameters for the {@link LanguageServerModule.onDidChangeConfiguration}
- * event.
- *
- * Note: This is not the same as {@link LSP.DidChangeConfigurationParams}, which
- * is used for the {@link LSP.DidChangeConfigurationNotification}.
- */
-export interface DidChangeConfigurationParams {
-	settings: LanguageServerOptions;
-}
-
 /**
  * A language server module.
  */
@@ -137,16 +126,10 @@ export interface LanguageServerModule {
 	onDidRegisterHandlers?: () => void;
 
 	/**
-	 * Handler called after the language server has finished receiving, parsing,
-	 * and setting updated configuration.
-	 */
-	onDidChangeConfiguration?: (params: DidChangeConfigurationParams) => void;
-
-	/**
-	 * Handler called after the languages for which documents should be
-	 * validated have changed.
+	 * Handler called after the language server has finished responding to the
+	 * onDidChangeConfiguration event.
 	 */
-	onDidChangeValidateLanguages?: (params: DidChangeValidateLanguagesParams) => void;
+	onDidChangeConfiguration?: () => void;
 
 	// eslint-disable-next-line @typescript-eslint/no-explicit-any
 	[key: string | symbol]: any;
diff --git a/jest-runner-vscode.config.js b/test/e2e/jest-runner-vscode.config.js
similarity index 54%
rename from jest-runner-vscode.config.js
rename to test/e2e/jest-runner-vscode.config.js
index fe6f050..983e4ea 100644
--- a/jest-runner-vscode.config.js
+++ b/test/e2e/jest-runner-vscode.config.js
@@ -1,11 +1,14 @@
 'use strict';
 
+const path = require('path');
+
 /** @type {import('jest-runner-vscode').RunnerOptions} */
 const config = {
 	version: '1.56.2',
 	launchArgs: ['--disable-extensions'],
 	openInFolder: true,
-	extensionDevelopmentPath: __dirname,
+	workspaceDir: path.join(__dirname, 'workspace/workspace.code-workspace'),
+	extensionDevelopmentPath: path.join(__dirname, '../..'),
 };
 
 module.exports = config;
diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json
index eee270a..55210e5 100644
--- a/tsconfig.scripts.json
+++ b/tsconfig.scripts.json
@@ -4,6 +4,6 @@
 		"typeRoots": ["./types", "./node_modules/@types"],
 		"types": ["node", "babel__core"]
 	},
-	"include": ["*.js", "package.json", "scripts/**/*"],
+	"include": ["*.js", "package.json", "scripts/**/*", "test/e2e/jest-runner-vscode.config.js"],
 	"exclude": ["**/coverage"]
 }


Test patch:
diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
index 80187e1..eca7b5d 100644
--- a/.github/workflows/testing.yml
+++ b/.github/workflows/testing.yml
@@ -54,7 +54,7 @@ jobs:
         id: vscode-cache
         with:
           path: .vscode-test
-          key: ${{ runner.os }}-vscode-${{ hashFiles('jest-runner-vscode.config.js') }}
+          key: ${{ runner.os }}-vscode-${{ hashFiles('test/e2e/jest-runner-vscode.config.js') }}
           restore-keys: |
             ${{ runner.os }}-vscode-
 
diff --git a/src/extension/__tests__/extension.ts b/src/extension/__tests__/extension.ts
index b4c9c9e..8f0f86f 100644
--- a/src/extension/__tests__/extension.ts
+++ b/src/extension/__tests__/extension.ts
@@ -10,7 +10,10 @@ jest.mock('vscode-languageclient/node', () => ({
 import { EventEmitter } from 'events';
 import vscode, { window } from 'vscode';
 import { LanguageClient, SettingMonitor, NodeModule } from 'vscode-languageclient/node';
-import { Notification } from '../../server';
+import {
+	DidRegisterDocumentFormattingEditProviderNotificationParams,
+	Notification,
+} from '../../server';
 import { activate } from '../extension';
 import { ApiEvent } from '../types';
 
@@ -96,7 +99,6 @@ describe('Extension entry point', () => {
 		const api = activate(mockExtensionContext);
 
 		expect(api).toBeInstanceOf(EventEmitter);
-		expect(api.formattingReady).toBe(false);
 	});
 
 	it('should create a language client', () => {
@@ -213,25 +215,25 @@ describe('Extension entry point', () => {
 		expect(onNotification.mock.calls[0][1]).toBeInstanceOf(Function);
 	});
 
-	it('should set the formattingReady flag to true when the DidRegisterDocumentFormattingEditProvider notification is received', () => {
-		const api = activate(mockExtensionContext);
-
-		afterOnReady.mock.calls[0][0]();
-		onNotification.mock.calls[0][1]();
-
-		expect(api.formattingReady).toBe(true);
-	});
-
 	it('should emit the DidRegisterDocumentFormattingEditProvider event when the DidRegisterDocumentFormattingEditProvider notification is received', async () => {
 		const api = activate(mockExtensionContext);
 
-		const promise = new Promise<void>((resolve) => {
-			api.on(ApiEvent.DidRegisterDocumentFormattingEditProvider, resolve);
-		});
+		const promise = new Promise<DidRegisterDocumentFormattingEditProviderNotificationParams>(
+			(resolve) => {
+				api.on(ApiEvent.DidRegisterDocumentFormattingEditProvider, resolve);
+			},
+		);
+
+		const params: DidRegisterDocumentFormattingEditProviderNotificationParams = {
+			uri: 'file:///foo.css',
+			options: {
+				documentSelector: [{ scheme: 'file', pattern: '/foo.css' }],
+			},
+		};
 
 		afterOnReady.mock.calls[0][0]();
-		onNotification.mock.calls[0][1]();
+		onNotification.mock.calls[0][1](params);
 
-		await expect(promise).resolves.toBeUndefined();
+		await expect(promise).resolves.toStrictEqual(params);
 	});
 });
diff --git a/src/server/__tests__/__snapshots__/server.ts.snap b/src/server/__tests__/__snapshots__/server.ts.snap
index b2beaa8..b99c081 100644
--- a/src/server/__tests__/__snapshots__/server.ts.snap
+++ b/src/server/__tests__/__snapshots__/server.ts.snap
@@ -4,34 +4,28 @@ exports[`StylelintLanguageServer should accept server modules 1`] = `
 Object {
   "context": Object {
     "connection": Object {
+      "client": Object {
+        "register": [MockFunction],
+      },
       "listen": [MockFunction],
       "onDidChangeConfiguration": [MockFunction],
       "onDidChangeWatchedFiles": [MockFunction],
       "onInitialize": [MockFunction],
+      "onInitialized": [MockFunction],
       "sendDiagnostics": [MockFunction],
+      "workspace": Object {
+        "getConfiguration": [MockFunction],
+      },
     },
     "displayError": [Function],
-    "documents": TextDocuments {
-      "all": [MockFunction],
-      "get": [MockFunction],
-      "keys": [MockFunction],
+    "documents": Object {
       "listen": [MockFunction],
-      "onWillSaveWaitUntil": [MockFunction],
+      "onDidClose": [MockFunction],
     },
     "getFixes": [Function],
     "getModule": [Function],
+    "getOptions": [Function],
     "lintDocument": [Function],
-    "options": Object {
-      "packageManager": "npm",
-      "snippet": Array [
-        "css",
-        "postcss",
-      ],
-      "validate": Array [
-        "css",
-        "postcss",
-      ],
-    },
     "resolveStylelint": [Function],
     "runner": StylelintRunner {
       "lintDocument": [MockFunction],
@@ -198,70 +192,6 @@ Object {
 }
 `;
 
-exports[`StylelintLanguageServer should fire onDidChangeValidateLanguages when first settings are sent to server 1`] = `
-Object {
-  "config": undefined,
-  "configBasedir": undefined,
-  "configFile": undefined,
-  "customSyntax": undefined,
-  "ignoreDisables": undefined,
-  "packageManager": "npm",
-  "reportInvalidScopeDisables": undefined,
-  "reportNeedlessDisables": undefined,
-  "snippet": Array [
-    "css",
-    "postcss",
-  ],
-  "stylelintPath": undefined,
-  "validate": Array [
-    "css",
-    "postcss",
-  ],
-}
-`;
-
-exports[`StylelintLanguageServer should fire onDidChangeValidateLanguages when first settings are sent to server 2`] = `
-Object {
-  "languages": Set {
-    "css",
-    "postcss",
-  },
-  "removedLanguages": Set {},
-}
-`;
-
-exports[`StylelintLanguageServer should fire onDidChangeValidateLanguages when validate option changes 1`] = `
-Object {
-  "config": undefined,
-  "configBasedir": undefined,
-  "configFile": undefined,
-  "customSyntax": undefined,
-  "ignoreDisables": undefined,
-  "packageManager": "npm",
-  "reportInvalidScopeDisables": undefined,
-  "reportNeedlessDisables": undefined,
-  "snippet": Array [
-    "css",
-    "postcss",
-  ],
-  "stylelintPath": undefined,
-  "validate": Array [
-    "css",
-  ],
-}
-`;
-
-exports[`StylelintLanguageServer should fire onDidChangeValidateLanguages when validate option changes 2`] = `
-Object {
-  "languages": Set {
-    "css",
-  },
-  "removedLanguages": Set {
-    "postcss",
-  },
-}
-`;
-
 exports[`StylelintLanguageServer should not accept modules with a non-string ID 1`] = `"Module IDs must be strings"`;
 
 exports[`StylelintLanguageServer should not accept modules with duplicate IDs 1`] = `"Module with ID \\"test-module\\" already registered"`;
@@ -269,51 +199,3 @@ exports[`StylelintLanguageServer should not accept modules with duplicate IDs 1`
 exports[`StylelintLanguageServer should not accept modules without an ID 1`] = `"Modules must have an ID"`;
 
 exports[`StylelintLanguageServer should prevent modules from modifying context properties 1`] = `"Cannot set read-only property"`;
-
-exports[`StylelintLanguageServer should receive settings from the client and pass them to modules with defaults 1`] = `
-Object {
-  "config": undefined,
-  "configBasedir": undefined,
-  "configFile": undefined,
-  "customSyntax": undefined,
-  "ignoreDisables": undefined,
-  "packageManager": "npm",
-  "reportInvalidScopeDisables": undefined,
-  "reportNeedlessDisables": undefined,
-  "snippet": Array [
-    "css",
-    "postcss",
-  ],
-  "stylelintPath": undefined,
-  "validate": Array [
-    "css",
-    "postcss",
-  ],
-}
-`;
-
-exports[`StylelintLanguageServer should receive updates to settings from the client and pass them to modules 1`] = `
-Object {
-  "config": Object {
-    "rules": Object {
-      "block-no-empty": true,
-    },
-  },
-  "configBasedir": undefined,
-  "configFile": undefined,
-  "customSyntax": undefined,
-  "ignoreDisables": true,
-  "packageManager": "npm",
-  "reportInvalidScopeDisables": undefined,
-  "reportNeedlessDisables": undefined,
-  "snippet": Array [
-    "css",
-    "postcss",
-  ],
-  "stylelintPath": undefined,
-  "validate": Array [
-    "css",
-    "postcss",
-  ],
-}
-`;
diff --git a/src/server/__tests__/server.ts b/src/server/__tests__/server.ts
index 8f50739..f4c972a 100644
--- a/src/server/__tests__/server.ts
+++ b/src/server/__tests__/server.ts
@@ -1,3 +1,4 @@
+jest.mock('vscode-languageserver/node');
 jest.mock('../../utils/lsp');
 jest.mock('../../utils/stylelint');
 jest.mock('../../utils/documents');
@@ -5,7 +6,8 @@ jest.mock('../../utils/packages');
 
 import type { Connection, WorkDoneProgressReporter } from 'vscode-languageserver';
 import type { TextDocument, TextEdit } from 'vscode-languageserver-textdocument';
-import type LSP from 'vscode-languageserver-protocol';
+import { TextDocuments } from 'vscode-languageserver/node';
+import * as LSP from 'vscode-languageserver-protocol';
 import type winston from 'winston';
 import { displayError } from '../../utils/lsp';
 import { StylelintRunner } from '../../utils/stylelint';
@@ -13,27 +15,34 @@ import { getFixes } from '../../utils/documents';
 import { StylelintResolver } from '../../utils/packages';
 import { StylelintLanguageServer } from '../server';
 import type {
-	DidChangeConfigurationParams,
-	DidChangeValidateLanguagesParams,
 	LanguageServerContext,
 	LanguageServerModule,
 	LanguageServerModuleConstructor,
 	LanguageServerModuleConstructorParameters,
-	LanguageServerOptions,
 } from '../types';
 
 const mockDisplayError = displayError as jest.MockedFunction<typeof displayError>;
 const mockRunner = StylelintRunner as jest.Mock<StylelintRunner>;
 const mockGetFixes = getFixes as jest.MockedFunction<typeof getFixes>;
 const mockResolver = StylelintResolver as jest.Mock<StylelintResolver>;
+const mockTextDocuments = TextDocuments as jest.Mock<TextDocuments<TextDocument>>;
 
-const mockConnection = {
+const mockConnectionBase = {
 	listen: jest.fn(),
 	onInitialize: jest.fn(),
+	onInitialized: jest.fn(),
 	onDidChangeConfiguration: jest.fn(),
 	onDidChangeWatchedFiles: jest.fn(),
 	sendDiagnostics: jest.fn(),
-} as unknown as jest.Mocked<Connection>;
+	client: {
+		register: jest.fn(),
+	},
+	workspace: {
+		getConfiguration: jest.fn(),
+	},
+};
+
+const mockConnection = mockConnectionBase as unknown as jest.Mocked<Connection>;
 
 const mockLogger = {
 	debug: jest.fn(),
@@ -47,6 +56,11 @@ const mockLogger = {
 describe('StylelintLanguageServer', () => {
 	beforeEach(() => {
 		jest.clearAllMocks();
+
+		mockTextDocuments.mockReturnValue({
+			listen: jest.fn(),
+			onDidClose: jest.fn(),
+		} as unknown as TextDocuments<TextDocument>);
 	});
 
 	test('should be constructable', () => {
@@ -212,6 +226,66 @@ describe('StylelintLanguageServer', () => {
 		expect(result).toMatchSnapshot();
 	});
 
+	test('if workspace/configuration is not supported, should not register DidChangeConfigurationNotification', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.start();
+
+		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
+
+		onInitializeHandler(
+			{ capabilities: {} } as LSP.InitializeParams,
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		const onInitializedHandler = mockConnection.onInitialized.mock.calls[0][0];
+
+		onInitializedHandler({});
+
+		expect(mockLogger.debug).toHaveBeenCalledWith('received onInitialized', { params: {} });
+		expect(mockLogger.debug).not.toHaveBeenCalledWith(
+			'Registering DidChangeConfigurationNotification',
+		);
+		expect(mockConnection.client.register).not.toHaveBeenCalledWith(
+			LSP.DidChangeConfigurationNotification.type,
+		);
+	});
+
+	test('if workspace/configuration is supported, should register DidChangeConfigurationNotification', () => {
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+		});
+
+		server.start();
+
+		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
+
+		onInitializeHandler(
+			{
+				capabilities: {
+					workspace: { configuration: true },
+				},
+			} as LSP.InitializeParams,
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		const onInitializedHandler = mockConnection.onInitialized.mock.calls[0][0];
+
+		onInitializedHandler({});
+
+		expect(mockLogger.debug).toHaveBeenCalledWith('received onInitialized', { params: {} });
+		expect(mockLogger.debug).toHaveBeenCalledWith('Registering DidChangeConfigurationNotification');
+		expect(mockConnection.client.register).toHaveBeenCalledWith(
+			LSP.DidChangeConfigurationNotification.type,
+		);
+	});
+
 	test('should display and log errors thrown by module handlers', () => {
 		const error = new Error('Test error');
 
@@ -287,14 +361,14 @@ describe('StylelintLanguageServer', () => {
 		expect(module?.value).toBe(5);
 	});
 
-	test('should receive settings from the client and pass them to modules with defaults', () => {
-		let options: LanguageServerOptions | undefined;
+	test('when workspace/configuration is not available, context.getOptions should return global options', async () => {
+		let getOptions: LanguageServerContext['getOptions'] | undefined;
 
 		class TestModule {
 			static id = 'test-module';
 
-			onDidChangeConfiguration({ settings }: DidChangeConfigurationParams) {
-				options = settings;
+			constructor({ context }: LanguageServerModuleConstructorParameters) {
+				getOptions = context.getOptions;
 			}
 		}
 
@@ -317,19 +391,26 @@ describe('StylelintLanguageServer', () => {
 		const onDidChangeConfigurationHandler =
 			mockConnection.onDidChangeConfiguration.mock.calls[0][0];
 
-		onDidChangeConfigurationHandler({ settings: { stylelint: {} } });
+		onDidChangeConfigurationHandler({ settings: {} });
+
+		const options = await getOptions?.('uri');
 
-		expect(options).toMatchSnapshot();
+		expect(mockConnection.workspace.getConfiguration).not.toHaveBeenCalled();
+		expect(options).toEqual({
+			packageManager: 'npm',
+			validate: ['css', 'postcss'],
+			snippet: ['css', 'postcss'],
+		});
 	});
 
-	test('should receive updates to settings from the client and pass them to modules', () => {
-		let options: LanguageServerOptions | undefined;
+	test('when workspace/configuration is not available, context.getOptions should gracefully handle missing section', async () => {
+		let getOptions: LanguageServerContext['getOptions'] | undefined;
 
 		class TestModule {
 			static id = 'test-module';
 
-			onDidChangeConfiguration({ settings }: DidChangeConfigurationParams) {
-				options = settings;
+			constructor({ context }: LanguageServerModuleConstructorParameters) {
+				getOptions = context.getOptions;
 			}
 		}
 
@@ -349,40 +430,54 @@ describe('StylelintLanguageServer', () => {
 			{} as WorkDoneProgressReporter,
 		);
 
+		const initialOptions = await getOptions?.('uri');
+
 		const onDidChangeConfigurationHandler =
 			mockConnection.onDidChangeConfiguration.mock.calls[0][0];
 
-		onDidChangeConfigurationHandler({ settings: { stylelint: {} } });
-
 		onDidChangeConfigurationHandler({
 			settings: {
 				stylelint: {
-					config: {
-						rules: {
-							'block-no-empty': true,
-						},
-					},
-					ignoreDisables: true,
+					validate: ['css'],
 				},
 			},
 		});
 
-		expect(options).toMatchSnapshot();
+		const changedOptions = await getOptions?.('uri');
+
+		expect(mockConnection.workspace.getConfiguration).not.toHaveBeenCalled();
+		expect(initialOptions).toEqual({
+			packageManager: 'npm',
+			validate: ['css', 'postcss'],
+			snippet: ['css', 'postcss'],
+		});
+		expect(changedOptions).toEqual({
+			packageManager: 'npm',
+			validate: ['css'],
+			snippet: ['css', 'postcss'],
+		});
 	});
 
-	test('should fire onDidChangeValidateLanguages when first settings are sent to server', () => {
-		let options: LanguageServerOptions | undefined;
-		let languageParams: DidChangeValidateLanguagesParams | undefined;
+	test('when workspace/configuration is available, context.getOptions should return resource-scoped options', async () => {
+		mockConnectionBase.workspace.getConfiguration.mockImplementation(
+			({ scopeUri, section }: LSP.ConfigurationItem) => {
+				if (section !== 'stylelint') {
+					return {};
+				}
+
+				return scopeUri === 'uri'
+					? { packageManager: 'yarn' }
+					: { packageManager: 'npm', snippet: ['scss'] };
+			},
+		);
+
+		let getOptions: LanguageServerContext['getOptions'] | undefined;
 
 		class TestModule {
 			static id = 'test-module';
 
-			onDidChangeConfiguration({ settings }: DidChangeConfigurationParams) {
-				options = settings;
-			}
-
-			onDidChangeValidateLanguages(params: DidChangeValidateLanguagesParams) {
-				languageParams = params;
+			constructor({ context }: LanguageServerModuleConstructorParameters) {
+				getOptions = context.getOptions;
 			}
 		}
 
@@ -397,33 +492,58 @@ describe('StylelintLanguageServer', () => {
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
 		onInitializeHandler(
-			{ capabilities: {} } as LSP.InitializeParams,
+			{
+				capabilities: {
+					workspace: { configuration: true },
+				},
+			} as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
-		const onDidChangeConfigurationHandler =
-			mockConnection.onDidChangeConfiguration.mock.calls[0][0];
-
-		onDidChangeConfigurationHandler({ settings: { stylelint: {} } });
+		const options1 = await getOptions?.('uri');
+		const options2 = await getOptions?.('uri2');
 
-		expect(options).toMatchSnapshot();
-		expect(languageParams).toMatchSnapshot();
+		expect(mockConnection.workspace.getConfiguration).toHaveBeenCalledWith({
+			scopeUri: 'uri',
+			section: 'stylelint',
+		});
+		expect(mockConnection.workspace.getConfiguration).toHaveBeenCalledWith({
+			scopeUri: 'uri2',
+			section: 'stylelint',
+		});
+		expect(options1).toEqual({
+			packageManager: 'yarn',
+			validate: ['css', 'postcss'],
+			snippet: ['css', 'postcss'],
+		});
+		expect(options2).toEqual({
+			packageManager: 'npm',
+			validate: ['css', 'postcss'],
+			snippet: ['scss'],
+		});
 	});
 
-	test('should fire onDidChangeValidateLanguages when validate option changes', () => {
-		let options: LanguageServerOptions | undefined;
-		let languageParams: DidChangeValidateLanguagesParams | undefined;
+	test('when workspace/configuration is available, context.getOptions should cache resource-scoped options', async () => {
+		mockConnectionBase.workspace.getConfiguration.mockImplementation(
+			({ scopeUri, section }: LSP.ConfigurationItem) => {
+				if (section !== 'stylelint') {
+					return {};
+				}
+
+				return scopeUri === 'uri'
+					? { packageManager: 'yarn' }
+					: { packageManager: 'npm', snippet: ['scss'] };
+			},
+		);
+
+		let getOptions: LanguageServerContext['getOptions'] | undefined;
 
 		class TestModule {
 			static id = 'test-module';
 
-			onDidChangeConfiguration({ settings }: DidChangeConfigurationParams) {
-				options = settings;
-			}
-
-			onDidChangeValidateLanguages(params: DidChangeValidateLanguagesParams) {
-				languageParams = params;
+			constructor({ context }: LanguageServerModuleConstructorParameters) {
+				getOptions = context.getOptions;
 			}
 		}
 
@@ -438,37 +558,123 @@ describe('StylelintLanguageServer', () => {
 		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
 
 		onInitializeHandler(
-			{ capabilities: {} } as LSP.InitializeParams,
+			{
+				capabilities: {
+					workspace: { configuration: true },
+				},
+			} as LSP.InitializeParams,
 			{} as LSP.CancellationToken,
 			{} as WorkDoneProgressReporter,
 		);
 
-		const onDidChangeConfigurationHandler =
-			mockConnection.onDidChangeConfiguration.mock.calls[0][0];
+		const options1A = await getOptions?.('uri');
+		const options1B = await getOptions?.('uri');
+		const options2A = await getOptions?.('uri2');
+		const options2B = await getOptions?.('uri2');
 
-		onDidChangeConfigurationHandler({ settings: { stylelint: {} } });
+		expect(mockConnection.workspace.getConfiguration).toHaveBeenCalledTimes(2);
+		expect(options1A).toEqual(options1B);
+		expect(options2A).toEqual(options2B);
+	});
 
-		onDidChangeConfigurationHandler({
-			settings: {
-				stylelint: {
-					validate: ['css'],
-				},
+	test('when workspace/configuration is available, on documents.onDidClose, should clear cached options', async () => {
+		mockConnectionBase.workspace.getConfiguration.mockImplementation(
+			({ scopeUri, section }: LSP.ConfigurationItem) => {
+				if (section !== 'stylelint') {
+					return {};
+				}
+
+				return scopeUri === 'uri'
+					? { packageManager: 'yarn' }
+					: { packageManager: 'npm', snippet: ['scss'] };
 			},
+		);
+
+		let getOptions: LanguageServerContext['getOptions'] | undefined;
+
+		class TestModule {
+			static id = 'test-module';
+
+			constructor({ context }: LanguageServerModuleConstructorParameters) {
+				getOptions = context.getOptions;
+			}
+		}
+
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			logger: mockLogger,
+			modules: [TestModule],
 		});
 
-		expect(options).toMatchSnapshot();
-		expect(languageParams).toMatchSnapshot();
+		server.start();
+
+		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
+
+		onInitializeHandler(
+			{
+				capabilities: {
+					workspace: { configuration: true },
+				},
+			} as LSP.InitializeParams,
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		const options1A = await getOptions?.('uri');
+		const options1B = await getOptions?.('uri');
+		const options2A = await getOptions?.('uri2');
+		const options2B = await getOptions?.('uri2');
+
+		const onDidCloseHandler = mockTextDocuments.mock.results[0].value.onDidClose.mock.calls[0][0];
+
+		onDidCloseHandler({ document: { uri: 'uri' } });
+
+		const options1C = await getOptions?.('uri');
+		const options2C = await getOptions?.('uri2');
+
+		expect(mockConnection.workspace.getConfiguration).toHaveBeenCalledTimes(3);
+		expect(options1A).toEqual(options1B);
+		expect(options1A).toEqual(options1C);
+		expect(options2A).toEqual(options2B);
+		expect(options2A).toEqual(options2C);
 	});
 
-	test('with debug log level, should log changed languages', () => {
-		mockLogger.isDebugEnabled.mockReturnValue(true);
+	test('when workspace/configuration is available, onDidChangeConfiguration should clear all cached options', async () => {
+		let getOptions: LanguageServerContext['getOptions'] | undefined;
+
+		class TestModule {
+			static id = 'test-module';
+
+			constructor({ context }: LanguageServerModuleConstructorParameters) {
+				getOptions = context.getOptions;
+			}
+		}
+
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			logger: mockLogger,
+			modules: [TestModule],
 		});
 
 		server.start();
 
+		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
+
+		onInitializeHandler(
+			{
+				capabilities: {
+					workspace: { configuration: true },
+				},
+			} as LSP.InitializeParams,
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		const options1A = await getOptions?.('uri');
+		const options1B = await getOptions?.('uri');
+		const options2A = await getOptions?.('uri2');
+		const options2B = await getOptions?.('uri2');
+
 		const onDidChangeConfigurationHandler =
 			mockConnection.onDidChangeConfiguration.mock.calls[0][0];
 
@@ -480,21 +686,45 @@ describe('StylelintLanguageServer', () => {
 			},
 		});
 
-		expect(mockLogger.debug).toHaveBeenCalledWith('Languages that should be validated changed', {
-			languages: ['css'],
-			removedLanguages: ['postcss'],
-		});
+		const options1C = await getOptions?.('uri');
+		const options2C = await getOptions?.('uri2');
+
+		expect(mockConnection.workspace.getConfiguration).toHaveBeenCalledTimes(4);
+		expect(options1A).toEqual(options1B);
+		expect(options1A).toEqual(options1C);
+		expect(options2A).toEqual(options2B);
+		expect(options2A).toEqual(options2C);
 	});
 
-	test('without debug log level, should not log changed languages', () => {
-		mockLogger.isDebugEnabled.mockReturnValue(false);
+	test('when workspace/configuration is available, onDidChangeConfiguration should fire module handlers', async () => {
+		const moduleHandler = jest.fn();
+
+		class TestModule {
+			static id = 'test-module';
+
+			onDidChangeConfiguration = moduleHandler;
+		}
+
 		const server = new StylelintLanguageServer({
 			connection: mockConnection,
 			logger: mockLogger,
+			modules: [TestModule],
 		});
 
 		server.start();
 
+		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
+
+		onInitializeHandler(
+			{
+				capabilities: {
+					workspace: { configuration: true },
+				},
+			} as LSP.InitializeParams,
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
 		const onDidChangeConfigurationHandler =
 			mockConnection.onDidChangeConfiguration.mock.calls[0][0];
 
@@ -506,13 +736,8 @@ describe('StylelintLanguageServer', () => {
 			},
 		});
 
-		expect(mockLogger.debug).not.toHaveBeenCalledWith(
-			'Languages that should be validated changed',
-			{
-				languages: ['css'],
-				removedLanguages: ['postcss'],
-			},
-		);
+		expect(moduleHandler).toHaveBeenCalledTimes(1);
+		expect(moduleHandler).toHaveBeenCalledWith();
 	});
 
 	test('should allow modules to lint documents using context.lintDocument', async () => {
diff --git a/src/server/modules/__tests__/auto-fix.ts b/src/server/modules/__tests__/auto-fix.ts
index 20c256a..8b4d7e1 100644
--- a/src/server/modules/__tests__/auto-fix.ts
+++ b/src/server/modules/__tests__/auto-fix.ts
@@ -1,10 +1,16 @@
 import { Position, TextEdit } from 'vscode-languageserver-types';
 import type winston from 'winston';
 import { CommandId } from '../../types';
-import type { LanguageServerModuleConstructorParameters } from '../../types';
+import type { LanguageServerOptions, LanguageServerModuleConstructorParameters } from '../../types';
 
 import { AutoFixModule } from '../auto-fix';
 
+const mockOptions: LanguageServerOptions = {
+	packageManager: 'npm',
+	validate: [],
+	snippet: [],
+};
+
 const mockContext = {
 	connection: {
 		onExecuteCommand: jest.fn(),
@@ -13,7 +19,7 @@ const mockContext = {
 		},
 	},
 	documents: { get: jest.fn() },
-	options: { validate: [] as string[] },
+	getOptions: jest.fn(async () => mockOptions),
 	getFixes: jest.fn(),
 };
 
@@ -30,7 +36,7 @@ const getParams = (passLogger = false) =>
 
 describe('AutoFixModule', () => {
 	beforeEach(() => {
-		mockContext.options.validate = [];
+		mockOptions.validate = [];
 		jest.clearAllMocks();
 	});
 
@@ -59,7 +65,7 @@ describe('AutoFixModule', () => {
 			languageId: 'bar',
 			version: 1,
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.getFixes.mockReturnValue([TextEdit.insert(Position.create(0, 0), 'text')]);
 		mockContext.connection.workspace.applyEdit.mockResolvedValue({ applied: true });
 
@@ -133,7 +139,7 @@ describe('AutoFixModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['baz'];
+		mockOptions.validate = ['baz'];
 
 		const module = new AutoFixModule(getParams(true));
 
@@ -162,7 +168,7 @@ describe('AutoFixModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['baz'];
+		mockOptions.validate = ['baz'];
 		mockLogger.isDebugEnabled.mockReturnValue(false);
 
 		const module = new AutoFixModule(getParams(true));
@@ -189,7 +195,7 @@ describe('AutoFixModule', () => {
 			languageId: 'bar',
 			version: 2,
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 
 		const module = new AutoFixModule(getParams(true));
 
@@ -217,7 +223,7 @@ describe('AutoFixModule', () => {
 			languageId: 'bar',
 			version: 1,
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.getFixes.mockReturnValue([TextEdit.insert(Position.create(0, 0), 'text')]);
 		mockContext.connection.workspace.applyEdit.mockResolvedValue(response);
 
@@ -247,7 +253,7 @@ describe('AutoFixModule', () => {
 			languageId: 'bar',
 			version: 1,
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.getFixes.mockReturnValue([TextEdit.insert(Position.create(0, 0), 'text')]);
 		mockContext.connection.workspace.applyEdit.mockRejectedValue(error);
 
diff --git a/src/server/modules/__tests__/code-action.ts b/src/server/modules/__tests__/code-action.ts
index 9663fdf..cbffeb8 100644
--- a/src/server/modules/__tests__/code-action.ts
+++ b/src/server/modules/__tests__/code-action.ts
@@ -1,14 +1,20 @@
 import { CodeActionKind, Position, TextEdit } from 'vscode-languageserver-types';
 import type winston from 'winston';
 import { CodeActionKind as StylelintCodeActionKind } from '../../types';
-import type { LanguageServerModuleConstructorParameters } from '../../types';
+import type { LanguageServerOptions, LanguageServerModuleConstructorParameters } from '../../types';
 
 import { CodeActionModule } from '../code-action';
 
+const mockOptions: LanguageServerOptions = {
+	packageManager: 'npm',
+	validate: [],
+	snippet: [],
+};
+
 const mockContext = {
 	connection: { onCodeAction: jest.fn() },
 	documents: { get: jest.fn() },
-	options: { validate: [] as string[] },
+	getOptions: jest.fn(async () => mockOptions),
 	getFixes: jest.fn(),
 };
 
@@ -25,7 +31,7 @@ const getParams = (passLogger = false) =>
 
 describe('CodeActionModule', () => {
 	beforeEach(() => {
-		mockContext.options.validate = [];
+		mockOptions.validate = [];
 		jest.clearAllMocks();
 	});
 
@@ -53,7 +59,7 @@ describe('CodeActionModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.getFixes.mockReturnValue([TextEdit.insert(Position.create(0, 0), 'text')]);
 
 		const module = new CodeActionModule(getParams(true));
@@ -79,7 +85,7 @@ describe('CodeActionModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.getFixes.mockReturnValue([TextEdit.insert(Position.create(0, 0), 'text')]);
 
 		const module = new CodeActionModule(getParams(true));
@@ -105,7 +111,7 @@ describe('CodeActionModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.getFixes.mockReturnValue([TextEdit.insert(Position.create(0, 0), 'text')]);
 
 		const module = new CodeActionModule(getParams(true));
@@ -190,7 +196,7 @@ describe('CodeActionModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['baz'];
+		mockOptions.validate = ['baz'];
 
 		const module = new CodeActionModule(getParams(true));
 
@@ -219,7 +225,7 @@ describe('CodeActionModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['baz'];
+		mockOptions.validate = ['baz'];
 		mockLogger.isDebugEnabled.mockReturnValue(false);
 
 		const module = new CodeActionModule(getParams(true));
diff --git a/src/server/modules/__tests__/completion.ts b/src/server/modules/__tests__/completion.ts
index 2c8fff6..dfdb566 100644
--- a/src/server/modules/__tests__/completion.ts
+++ b/src/server/modules/__tests__/completion.ts
@@ -3,17 +3,20 @@ import { TextDocument } from 'vscode-languageserver-textdocument';
 import type LSP from 'vscode-languageserver-protocol';
 import type winston from 'winston';
 import { DisableReportRuleNames } from '../../../utils/stylelint';
-import type { LanguageServerModuleConstructorParameters } from '../../types';
+import type { LanguageServerOptions, LanguageServerModuleConstructorParameters } from '../../types';
 
 import { CompletionModule } from '../completion';
 
+const mockOptions: LanguageServerOptions = {
+	packageManager: 'npm',
+	validate: [],
+	snippet: [],
+};
+
 const mockContext = {
 	connection: { onCompletion: jest.fn() },
 	documents: { get: jest.fn() },
-	options: {
-		validate: [] as string[],
-		snippet: [] as string[],
-	},
+	getOptions: jest.fn(async () => mockOptions),
 	getModule: jest.fn(),
 };
 
@@ -45,8 +48,8 @@ const createNeedlessDisableDiagnostic = ({
 
 describe('CompletionModule', () => {
 	beforeEach(() => {
-		mockContext.options.validate = [];
-		mockContext.options.snippet = [];
+		mockOptions.validate = [];
+		mockOptions.snippet = [];
 		jest.clearAllMocks();
 	});
 
@@ -93,8 +96,8 @@ describe('CompletionModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['baz'];
-		mockContext.options.snippet = ['bar'];
+		mockOptions.validate = ['baz'];
+		mockOptions.snippet = ['bar'];
 
 		const module = new CompletionModule(getParams(true));
 
@@ -123,8 +126,8 @@ describe('CompletionModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['bar'];
-		mockContext.options.snippet = ['baz'];
+		mockOptions.validate = ['bar'];
+		mockOptions.snippet = ['baz'];
 
 		const module = new CompletionModule(getParams(true));
 
@@ -153,8 +156,8 @@ describe('CompletionModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['bar'];
-		mockContext.options.snippet = ['baz'];
+		mockOptions.validate = ['bar'];
+		mockOptions.snippet = ['baz'];
 		mockLogger.isDebugEnabled.mockReturnValue(false);
 
 		const module = new CompletionModule(getParams(true));
@@ -181,8 +184,8 @@ describe('CompletionModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['bar'];
-		mockContext.options.snippet = ['bar'];
+		mockOptions.validate = ['bar'];
+		mockOptions.snippet = ['bar'];
 		mockContext.getModule.mockReturnValue(undefined);
 
 		const module = new CompletionModule(getParams(true));
@@ -205,8 +208,8 @@ describe('CompletionModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['bar'];
-		mockContext.options.snippet = ['bar'];
+		mockOptions.validate = ['bar'];
+		mockOptions.snippet = ['bar'];
 		mockContext.getModule.mockReturnValue({
 			getDiagnostics: () => [],
 		});
@@ -228,8 +231,8 @@ describe('CompletionModule', () => {
 
 	test('with no diagnostics at the same or next line, should return generic completions', async () => {
 		mockContext.documents.get.mockReturnValue(createDocument('a {\n  color: red;\n}'));
-		mockContext.options.validate = ['css'];
-		mockContext.options.snippet = ['css'];
+		mockOptions.validate = ['css'];
+		mockOptions.snippet = ['css'];
 		mockContext.getModule.mockReturnValue({
 			getDiagnostics: () => [
 				{
@@ -262,8 +265,8 @@ describe('CompletionModule', () => {
 
 	test('with diagnostics at the same line, should return disable comment completions for rule', async () => {
 		mockContext.documents.get.mockReturnValue(createDocument('a {\n  color: red;\n}'));
-		mockContext.options.validate = ['css'];
-		mockContext.options.snippet = ['css'];
+		mockOptions.validate = ['css'];
+		mockOptions.snippet = ['css'];
 		mockContext.getModule.mockReturnValue({
 			getDiagnostics: () => [
 				{
@@ -293,8 +296,8 @@ describe('CompletionModule', () => {
 		mockContext.documents.get.mockReturnValue(
 			createDocument('a {\n  font-weight: 400;\n  color: red;\n}'),
 		);
-		mockContext.options.validate = ['css'];
-		mockContext.options.snippet = ['css'];
+		mockOptions.validate = ['css'];
+		mockOptions.snippet = ['css'];
 		mockContext.getModule.mockReturnValue({
 			getDiagnostics: () => [
 				{
@@ -322,8 +325,8 @@ describe('CompletionModule', () => {
 
 	test('with needless disables reported for a diagnostic, should return generic completions', async () => {
 		mockContext.documents.get.mockReturnValue(createDocument('a {\n  color: red;\n}'));
-		mockContext.options.validate = ['css'];
-		mockContext.options.snippet = ['css'];
+		mockOptions.validate = ['css'];
+		mockOptions.snippet = ['css'];
 		mockContext.getModule.mockReturnValue({
 			getDiagnostics: () => [
 				{
@@ -360,8 +363,8 @@ describe('CompletionModule', () => {
   color: red;
 }`),
 		);
-		mockContext.options.validate = ['css'];
-		mockContext.options.snippet = ['css'];
+		mockOptions.validate = ['css'];
+		mockOptions.snippet = ['css'];
 		mockContext.getModule.mockReturnValue({
 			getDiagnostics: () => [
 				{
@@ -394,8 +397,8 @@ describe('CompletionModule', () => {
   color: red;
 }`),
 		);
-		mockContext.options.validate = ['css'];
-		mockContext.options.snippet = ['css'];
+		mockOptions.validate = ['css'];
+		mockOptions.snippet = ['css'];
 		mockContext.getModule.mockReturnValue({
 			getDiagnostics: () => [
 				{
@@ -428,8 +431,8 @@ describe('CompletionModule', () => {
   color: red;
 }`),
 		);
-		mockContext.options.validate = ['css'];
-		mockContext.options.snippet = ['css'];
+		mockOptions.validate = ['css'];
+		mockOptions.snippet = ['css'];
 		mockContext.getModule.mockReturnValue({
 			getDiagnostics: () => [
 				{
@@ -463,8 +466,8 @@ describe('CompletionModule', () => {
   font-weight: 400;
 }`),
 		);
-		mockContext.options.validate = ['css'];
-		mockContext.options.snippet = ['css'];
+		mockOptions.validate = ['css'];
+		mockOptions.snippet = ['css'];
 		mockContext.getModule.mockReturnValue({
 			getDiagnostics: () => [
 				{
diff --git a/src/server/modules/__tests__/formatter.ts b/src/server/modules/__tests__/formatter.ts
index fa03157..fe7ef2a 100644
--- a/src/server/modules/__tests__/formatter.ts
+++ b/src/server/modules/__tests__/formatter.ts
@@ -1,20 +1,54 @@
-import { DocumentFormattingRequest } from 'vscode-languageserver-protocol';
-import type LSP from 'vscode-languageserver-protocol';
+jest.mock('vscode-uri', () => {
+	const vscodeUri = jest.requireActual('vscode-uri');
+	const mock = jest.createMockFromModule<typeof vscodeUri>('vscode-uri');
+
+	mock.URI.parse = (uri: string) => {
+		const parsed = vscodeUri.URI.parse(uri);
+
+		if (parsed.scheme === 'file') {
+			return parsed;
+		}
+
+		const fsPath = parsed.fsPath?.replace(/\//g, '\\');
+
+		return {
+			...parsed,
+			fsPath,
+		};
+	};
+
+	return mock;
+});
+
+import * as LSP from 'vscode-languageserver-protocol';
 import { Position, TextEdit } from 'vscode-languageserver-types';
 import type winston from 'winston';
-import type { LanguageServerModuleConstructorParameters } from '../../types';
+import type { LanguageServerOptions, LanguageServerModuleConstructorParameters } from '../../types';
 
 import { Notification } from '../../types';
 import { FormatterModule } from '../formatter';
 
+const mockOptions: LanguageServerOptions = {
+	packageManager: 'npm',
+	validate: [],
+	snippet: [],
+};
+
 const mockContext = {
 	connection: {
 		onDocumentFormatting: jest.fn(),
+		onNotification: jest.fn(),
 		sendNotification: jest.fn(),
 		client: { register: jest.fn() },
 	},
-	documents: { get: jest.fn() },
-	options: { validate: [] as string[] },
+	documents: {
+		get: jest.fn(),
+		onDidOpen: jest.fn(),
+		onDidChangeContent: jest.fn(),
+		onDidClose: jest.fn(),
+		onDidSave: jest.fn(),
+	},
+	getOptions: jest.fn(async () => mockOptions),
 	getFixes: jest.fn(),
 };
 
@@ -31,7 +65,7 @@ const getParams = (passLogger = false) =>
 
 describe('FormatterModule', () => {
 	beforeEach(() => {
-		mockContext.options.validate = [];
+		mockOptions.validate = [];
 		jest.clearAllMocks();
 	});
 
@@ -81,7 +115,7 @@ describe('FormatterModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.getFixes.mockReturnValue([TextEdit.insert(Position.create(0, 0), 'text')]);
 
 		const module = new FormatterModule(getParams(true));
@@ -156,7 +190,7 @@ describe('FormatterModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['baz'];
+		mockOptions.validate = ['baz'];
 
 		const module = new FormatterModule(getParams(true));
 
@@ -185,7 +219,7 @@ describe('FormatterModule', () => {
 			uri: 'foo',
 			languageId: 'bar',
 		});
-		mockContext.options.validate = ['baz'];
+		mockOptions.validate = ['baz'];
 		mockLogger.isDebugEnabled.mockReturnValue(false);
 
 		const module = new FormatterModule(getParams(true));
@@ -209,28 +243,31 @@ describe('FormatterModule', () => {
 		);
 	});
 
-	test("with no debug log level, onDidChangeValidateLanguages shouldn't log languages", async () => {
-		mockLogger.isDebugEnabled.mockReturnValue(false);
+	test("without client dynamic registration support, documents.onDidOpen shouldn't register a formatter", async () => {
+		mockOptions.validate = ['bar'];
+
 		const module = new FormatterModule(getParams(true));
 
 		module.onInitialize({
 			capabilities: {
 				textDocument: {
-					formatting: { dynamicRegistration: true },
+					formatting: { dynamicRegistration: false },
 				},
 			},
 		} as unknown as LSP.InitializeParams);
 
-		await module.onDidChangeValidateLanguages({
-			languages: new Set(['foo']),
-			removedLanguages: new Set(),
-		});
+		module.onDidRegisterHandlers();
 
-		expect(mockLogger.debug).not.toHaveBeenCalledWith('Received onDidChangeValidateLanguages');
-		expect(mockLogger.debug).not.toHaveBeenCalledWith('Registering formatter for languages');
+		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
+
+		await onDidOpenHandler({ document: { uri: 'foo', languageId: 'bar' } });
+
+		expect(mockContext.connection.client.register).not.toHaveBeenCalled();
 	});
 
-	test("without client dynamic registration support, onDidChangeValidateLanguages shouldn't register a formatter", async () => {
+	test("without client dynamic registration support, documents.onDidChangeContent shouldn't register a formatter", async () => {
+		mockOptions.validate = ['bar'];
+
 		const module = new FormatterModule(getParams(true));
 
 		module.onInitialize({
@@ -241,15 +278,40 @@ describe('FormatterModule', () => {
 			},
 		} as unknown as LSP.InitializeParams);
 
-		await module.onDidChangeValidateLanguages({
-			languages: new Set(['foo']),
-			removedLanguages: new Set(),
-		});
+		module.onDidRegisterHandlers();
+
+		const onDidChangeContentHandler = mockContext.documents.onDidChangeContent.mock.calls[0][0];
+
+		await onDidChangeContentHandler({ document: { uri: 'foo', languageId: 'bar' } });
+
+		expect(mockContext.connection.client.register).not.toHaveBeenCalled();
+	});
+
+	test("without client dynamic registration support, documents.onDidSave shouldn't register a formatter", async () => {
+		mockOptions.validate = ['bar'];
+
+		const module = new FormatterModule(getParams(true));
+
+		module.onInitialize({
+			capabilities: {
+				textDocument: {
+					formatting: { dynamicRegistration: false },
+				},
+			},
+		} as unknown as LSP.InitializeParams);
+
+		module.onDidRegisterHandlers();
+
+		const onDidSaveHandler = mockContext.documents.onDidSave.mock.calls[0][0];
+
+		await onDidSaveHandler({ document: { uri: 'foo', languageId: 'bar' } });
 
 		expect(mockContext.connection.client.register).not.toHaveBeenCalled();
 	});
 
-	test('with client dynamic registration support, onDidChangeValidateLanguages should register a formatter', async () => {
+	test('with client dynamic registration support, documents.onDidOpen should register a formatter', async () => {
+		mockOptions.validate = ['bar'];
+
 		const module = new FormatterModule(getParams(true));
 
 		module.onInitialize({
@@ -260,18 +322,26 @@ describe('FormatterModule', () => {
 			},
 		} as unknown as LSP.InitializeParams);
 
-		await module.onDidChangeValidateLanguages({
-			languages: new Set(['foo']),
-			removedLanguages: new Set(),
-		});
+		module.onDidRegisterHandlers();
+
+		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' } });
+
+		expect(mockContext.connection.client.register).toHaveBeenCalledWith(
+			LSP.DocumentFormattingRequest.type,
+			{ documentSelector: [{ scheme: 'file', pattern: '/dir/test.css' }] },
+		);
 		expect(mockContext.connection.client.register).toHaveBeenCalledWith(
-			DocumentFormattingRequest.type,
-			{ documentSelector: [{ language: 'foo' }] },
+			LSP.DocumentFormattingRequest.type,
+			{ documentSelector: [{ scheme: 'scheme', pattern: '\\dir\\test.css' }] },
 		);
 	});
 
-	test('when a formatter is registered, a notification should be sent', async () => {
+	test('with client dynamic registration support, documents.onDidChangeContent should register a formatter', async () => {
+		mockOptions.validate = ['bar'];
+
 		const module = new FormatterModule(getParams(true));
 
 		module.onInitialize({
@@ -282,18 +352,23 @@ describe('FormatterModule', () => {
 			},
 		} as unknown as LSP.InitializeParams);
 
-		await module.onDidChangeValidateLanguages({
-			languages: new Set(['foo']),
-			removedLanguages: new Set(),
+		module.onDidRegisterHandlers();
+
+		const onDidChangeContentHandler = mockContext.documents.onDidChangeContent.mock.calls[0][0];
+
+		await onDidChangeContentHandler({
+			document: { uri: 'file:///dir/test.css', languageId: 'bar' },
 		});
 
-		expect(mockContext.connection.sendNotification).toHaveBeenCalledWith(
-			Notification.DidRegisterDocumentFormattingEditProvider,
-			{},
+		expect(mockContext.connection.client.register).toHaveBeenCalledWith(
+			LSP.DocumentFormattingRequest.type,
+			{ documentSelector: [{ scheme: 'file', pattern: '/dir/test.css' }] },
 		);
 	});
 
-	test('without languages to validate, onDidChangeValidateLanguages should register a formatter', async () => {
+	test('with client dynamic registration support, documents.onDidSave should register a formatter', async () => {
+		mockOptions.validate = ['bar'];
+
 		const module = new FormatterModule(getParams(true));
 
 		module.onInitialize({
@@ -304,16 +379,22 @@ describe('FormatterModule', () => {
 			},
 		} as unknown as LSP.InitializeParams);
 
-		await module.onDidChangeValidateLanguages({
-			languages: new Set(),
-			removedLanguages: new Set(),
-		});
+		module.onDidRegisterHandlers();
 
-		expect(mockContext.connection.client.register).not.toHaveBeenCalled();
+		const onDidSaveHandler = mockContext.documents.onDidSave.mock.calls[0][0];
+
+		await onDidSaveHandler({ document: { uri: 'file:///dir/test.css', languageId: 'bar' } });
+
+		expect(mockContext.connection.client.register).toHaveBeenCalledWith(
+			LSP.DocumentFormattingRequest.type,
+			{ documentSelector: [{ scheme: 'file', pattern: '/dir/test.css' }] },
+		);
 	});
 
-	test('when a formatter was already registered, onDidChangeValidateLanguages should dispose the old registration', async () => {
+	test('when a formatter was registered, documents.onDidClose should dispose the old registration', async () => {
+		mockOptions.validate = ['bar'];
 		mockLogger.isDebugEnabled.mockReturnValue(true);
+
 		const mockRegistration = { dispose: jest.fn() };
 
 		mockContext.connection.client.register.mockResolvedValueOnce(mockRegistration);
@@ -328,16 +409,166 @@ describe('FormatterModule', () => {
 			},
 		} as unknown as LSP.InitializeParams);
 
-		await module.onDidChangeValidateLanguages({
-			languages: new Set(['foo']),
-			removedLanguages: new Set(),
+		module.onDidRegisterHandlers();
+
+		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' } });
+
+		expect(mockRegistration.dispose).toHaveBeenCalled();
+		expect(mockLogger.debug).toHaveBeenCalledWith('Deregistering formatter for document', {
+			uri: 'file:///dir/test.css',
 		});
+	});
+
+	test('when a formatter was not registered, documents.onDidClose should not try to dispose a registration', async () => {
+		mockOptions.validate = ['bar'];
+		mockLogger.isDebugEnabled.mockReturnValue(true);
+
+		const module = new FormatterModule(getParams(true));
+
+		module.onInitialize({
+			capabilities: {
+				textDocument: {
+					formatting: { dynamicRegistration: true },
+				},
+			},
+		} as unknown as LSP.InitializeParams);
+
+		module.onDidRegisterHandlers();
+
+		const onDidCloseHandler = mockContext.documents.onDidClose.mock.calls[0][0];
 
-		await module.onDidChangeValidateLanguages({
-			languages: new Set(['bar']),
-			removedLanguages: new Set(['foo']),
+		await onDidCloseHandler({ document: { uri: 'file:///dir/test.css' } });
+
+		expect(mockLogger.debug).not.toHaveBeenCalledWith('Deregistering formatter for document', {
+			uri: 'file:///dir/test.css',
 		});
+	});
 
-		expect(mockRegistration.dispose).toHaveBeenCalled();
+	test('when formatters were registered, DidChangeConfigurationNotification should deregister all registrations', async () => {
+		mockOptions.validate = ['bar'];
+		mockLogger.isDebugEnabled.mockReturnValue(true);
+
+		const mockRegistration = { dispose: jest.fn() };
+
+		mockContext.connection.client.register.mockResolvedValue(mockRegistration);
+
+		const module = new FormatterModule(getParams(true));
+
+		module.onInitialize({
+			capabilities: {
+				textDocument: {
+					formatting: { dynamicRegistration: true },
+				},
+			},
+		} as unknown as LSP.InitializeParams);
+
+		module.onDidRegisterHandlers();
+
+		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' } });
+
+		const didChangeConfigurationNotificationHandler =
+			mockContext.connection.onNotification.mock.calls.find(
+				(call) => call[0] === LSP.DidChangeConfigurationNotification.type,
+			)[1];
+
+		await didChangeConfigurationNotificationHandler();
+
+		expect(mockRegistration.dispose).toHaveBeenCalledTimes(2);
+	});
+
+	test('when formatters were registered, DidChangeWorkspaceFoldersNotification should deregister all registrations', async () => {
+		mockOptions.validate = ['bar'];
+		mockLogger.isDebugEnabled.mockReturnValue(true);
+
+		const mockRegistration = { dispose: jest.fn() };
+
+		mockContext.connection.client.register.mockResolvedValue(mockRegistration);
+
+		const module = new FormatterModule(getParams(true));
+
+		module.onInitialize({
+			capabilities: {
+				textDocument: {
+					formatting: { dynamicRegistration: true },
+				},
+			},
+		} as unknown as LSP.InitializeParams);
+
+		module.onDidRegisterHandlers();
+
+		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' } });
+
+		const didChangeWorkspaceFoldersNotificationHandler =
+			mockContext.connection.onNotification.mock.calls.find(
+				(call) => call[0] === LSP.DidChangeWorkspaceFoldersNotification.type,
+			)[1];
+
+		await didChangeWorkspaceFoldersNotificationHandler();
+
+		expect(mockRegistration.dispose).toHaveBeenCalledTimes(2);
+	});
+
+	test('with client dynamic registration support, only one formatter should be registered per document', async () => {
+		mockOptions.validate = ['bar'];
+
+		const module = new FormatterModule(getParams(true));
+
+		module.onInitialize({
+			capabilities: {
+				textDocument: {
+					formatting: { dynamicRegistration: true },
+				},
+			},
+		} as unknown as LSP.InitializeParams);
+
+		module.onDidRegisterHandlers();
+
+		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' } });
+
+		expect(mockContext.connection.client.register).toHaveBeenCalledTimes(1);
+	});
+
+	test('when a formatter is registered, a notification should be sent', async () => {
+		mockOptions.validate = ['bar'];
+
+		const module = new FormatterModule(getParams(true));
+
+		module.onInitialize({
+			capabilities: {
+				textDocument: {
+					formatting: { dynamicRegistration: true },
+				},
+			},
+		} as unknown as LSP.InitializeParams);
+
+		module.onDidRegisterHandlers();
+
+		const onDidOpenHandler = mockContext.documents.onDidOpen.mock.calls[0][0];
+
+		await onDidOpenHandler({ document: { uri: 'file:///dir/test.css', languageId: 'bar' } });
+
+		expect(mockContext.connection.sendNotification).toHaveBeenCalledWith(
+			Notification.DidRegisterDocumentFormattingEditProvider,
+			{
+				uri: 'file:///dir/test.css',
+				options: {
+					documentSelector: [{ scheme: 'file', pattern: '/dir/test.css' }],
+				},
+			},
+		);
 	});
 });
diff --git a/src/server/modules/__tests__/old-stylelint-warning.ts b/src/server/modules/__tests__/old-stylelint-warning.ts
index 613c95f..d45d5ca 100644
--- a/src/server/modules/__tests__/old-stylelint-warning.ts
+++ b/src/server/modules/__tests__/old-stylelint-warning.ts
@@ -10,7 +10,7 @@ import type winston from 'winston';
 import { getWorkspaceFolder } from '../../../utils/documents';
 import { findPackageRoot } from '../../../utils/packages';
 import { OldStylelintWarningModule } from '../old-stylelint-warning';
-import type { LanguageServerModuleConstructorParameters } from '../../types';
+import type { LanguageServerOptions, LanguageServerModuleConstructorParameters } from '../../types';
 
 const mockedFS = fs as tests.mocks.FSPromisesModule;
 const mockedPath = path as tests.mocks.PathModule;
@@ -19,6 +19,12 @@ const mockedGetWorkspaceFolder = getWorkspaceFolder as jest.MockedFunction<
 >;
 const mockedFindPackageRoot = findPackageRoot as jest.MockedFunction<typeof findPackageRoot>;
 
+const mockOptions: LanguageServerOptions = {
+	packageManager: 'npm',
+	validate: [],
+	snippet: [],
+};
+
 const mockContext = {
 	connection: {
 		window: {
@@ -27,7 +33,7 @@ const mockContext = {
 		},
 	},
 	documents: { onDidOpen: jest.fn() },
-	options: { validate: [] as string[] },
+	getOptions: jest.fn(async () => mockOptions),
 	displayError: jest.fn(),
 	resolveStylelint: jest.fn(),
 };
@@ -47,7 +53,7 @@ const getParams = (passLogger = false) =>
 describe('OldStylelintWarningModule', () => {
 	beforeEach(() => {
 		mockedPath.__mockPlatform('posix');
-		mockContext.options.validate = [];
+		mockOptions.validate = [];
 		jest.clearAllMocks();
 	});
 
@@ -65,7 +71,7 @@ describe('OldStylelintWarningModule', () => {
 	});
 
 	test('if document language ID is not in options, should not warn', async () => {
-		mockContext.options.validate = ['baz'];
+		mockOptions.validate = ['baz'];
 
 		const module = new OldStylelintWarningModule(getParams(true));
 
@@ -89,7 +95,7 @@ describe('OldStylelintWarningModule', () => {
 
 	test('if document is not part of a workspace, should not warn', async () => {
 		mockedGetWorkspaceFolder.mockResolvedValue(undefined);
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 
 		const module = new OldStylelintWarningModule(getParams(true));
 
@@ -113,7 +119,7 @@ describe('OldStylelintWarningModule', () => {
 
 	test('if document has already been checked, should not warn', async () => {
 		mockedGetWorkspaceFolder.mockResolvedValue('/path');
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 
 		const module = new OldStylelintWarningModule(getParams(true));
 
@@ -146,7 +152,7 @@ describe('OldStylelintWarningModule', () => {
 			stylelint: {},
 			resolvedPath: '/path/node_modules/stylelint',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 
 		const module = new OldStylelintWarningModule(getParams(true));
 
@@ -176,7 +182,7 @@ describe('OldStylelintWarningModule', () => {
 			stylelint: {},
 			resolvedPath: '/path/node_modules/stylelint',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockedFS.readFile.mockRejectedValue(error);
 
 		const module = new OldStylelintWarningModule(getParams(true));
@@ -206,7 +212,7 @@ describe('OldStylelintWarningModule', () => {
 			stylelint: {},
 			resolvedPath: '/path/node_modules/stylelint',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockedFS.readFile.mockResolvedValue('{');
 
 		const module = new OldStylelintWarningModule(getParams(true));
@@ -240,7 +246,7 @@ describe('OldStylelintWarningModule', () => {
 			stylelint: {},
 			resolvedPath: '/path/node_modules/stylelint',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockedFS.readFile.mockResolvedValue('{}');
 
 		const module = new OldStylelintWarningModule(getParams(true));
@@ -266,7 +272,7 @@ describe('OldStylelintWarningModule', () => {
 			stylelint: {},
 			resolvedPath: '/path/node_modules/stylelint',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockedFS.readFile.mockResolvedValue('{"version": "foo"}');
 
 		const module = new OldStylelintWarningModule(getParams(true));
@@ -297,7 +303,7 @@ describe('OldStylelintWarningModule', () => {
 			stylelint: {},
 			resolvedPath: '/path/node_modules/stylelint',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockedFS.readFile.mockResolvedValue('{"version": "14.0.0"}');
 
 		const module = new OldStylelintWarningModule(getParams(true));
@@ -323,7 +329,7 @@ describe('OldStylelintWarningModule', () => {
 			stylelint: {},
 			resolvedPath: '/path/node_modules/stylelint',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockedFS.readFile.mockResolvedValue('{"version": "14.0.0-sdk"}');
 
 		const module = new OldStylelintWarningModule(getParams(true));
@@ -349,7 +355,7 @@ describe('OldStylelintWarningModule', () => {
 			stylelint: {},
 			resolvedPath: '/path/node_modules/stylelint',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockedFS.readFile.mockResolvedValue('{"version": "13.0.0"}');
 
 		const module = new OldStylelintWarningModule(getParams(true));
@@ -377,7 +383,7 @@ describe('OldStylelintWarningModule', () => {
 			stylelint: {},
 			resolvedPath: '/path/node_modules/stylelint',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.connection.window.showWarningMessage.mockResolvedValue(undefined);
 		mockedFS.readFile.mockResolvedValue('{"version": "13.0.0"}');
 
@@ -411,7 +417,7 @@ describe('OldStylelintWarningModule', () => {
 			stylelint: {},
 			resolvedPath: '/path/node_modules/stylelint',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.connection.window.showWarningMessage.mockResolvedValue({
 			title: 'Open migration guide',
 		});
@@ -452,7 +458,7 @@ describe('OldStylelintWarningModule', () => {
 			stylelint: {},
 			resolvedPath: '/path/node_modules/stylelint',
 		});
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.connection.window.showWarningMessage.mockResolvedValue({
 			title: 'Open migration guide',
 		});
diff --git a/src/server/modules/__tests__/validator.ts b/src/server/modules/__tests__/validator.ts
index fd1c27f..a86a4a3 100644
--- a/src/server/modules/__tests__/validator.ts
+++ b/src/server/modules/__tests__/validator.ts
@@ -1,7 +1,13 @@
 import { Range } from 'vscode-languageserver-types';
 import type winston from 'winston';
 import { ValidatorModule } from '../validator';
-import type { LanguageServerModuleConstructorParameters } from '../../types';
+import type { LanguageServerOptions, LanguageServerModuleConstructorParameters } from '../../types';
+
+const mockOptions: LanguageServerOptions = {
+	packageManager: 'npm',
+	validate: ['css', 'postcss'],
+	snippet: ['css', 'postcss'],
+};
 
 const mockContext = {
 	connection: {
@@ -13,7 +19,7 @@ const mockContext = {
 		onDidChangeContent: jest.fn(),
 		onDidClose: jest.fn(),
 	},
-	options: { validate: [] as string[] },
+	getOptions: jest.fn(async () => mockOptions),
 	displayError: jest.fn(),
 	lintDocument: jest.fn(),
 };
@@ -32,7 +38,7 @@ const getParams = (passLogger = false) =>
 
 describe('ValidatorModule', () => {
 	beforeEach(() => {
-		mockContext.options.validate = [];
+		mockOptions.validate = [];
 		jest.clearAllMocks();
 	});
 
@@ -56,7 +62,7 @@ describe('ValidatorModule', () => {
 	});
 
 	test('if document language ID is not in options, should not validate', async () => {
-		mockContext.options.validate = ['baz'];
+		mockOptions.validate = ['baz'];
 
 		const module = new ValidatorModule(getParams(true));
 
@@ -76,7 +82,7 @@ describe('ValidatorModule', () => {
 	});
 
 	test('if linting produces no results, should not validate', async () => {
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 
 		const module = new ValidatorModule(getParams(true));
 
@@ -93,7 +99,7 @@ describe('ValidatorModule', () => {
 	});
 
 	test('if linting produces results, should forward diagnostics to client', async () => {
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.lintDocument.mockResolvedValueOnce({
 			diagnostics: [
 				{
@@ -132,7 +138,7 @@ describe('ValidatorModule', () => {
 	test('if sending diagnostics fails, should display the error', async () => {
 		const error = new Error('foo');
 
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		mockContext.lintDocument.mockResolvedValueOnce({
 			diagnostics: [
 				{
@@ -177,7 +183,7 @@ describe('ValidatorModule', () => {
 	});
 
 	test('onInitialize should validate all documents', async () => {
-		mockContext.options.validate = ['baz'];
+		mockOptions.validate = ['baz'];
 		mockContext.lintDocument.mockImplementation((document) => {
 			return document.uri === 'foo'
 				? {
@@ -238,7 +244,7 @@ describe('ValidatorModule', () => {
 	});
 
 	test('onDidChangeWatchedFiles should validate all documents', async () => {
-		mockContext.options.validate = ['baz'];
+		mockOptions.validate = ['baz'];
 		mockContext.lintDocument.mockImplementation((document) => {
 			return document.uri === 'foo'
 				? {
@@ -300,7 +306,7 @@ describe('ValidatorModule', () => {
 	});
 
 	test('onDidChangeConfiguration should validate all documents', async () => {
-		mockContext.options.validate = ['baz'];
+		mockOptions.validate = ['baz'];
 		mockContext.lintDocument.mockImplementation((document) => {
 			return document.uri === 'foo'
 				? {
@@ -358,7 +364,7 @@ describe('ValidatorModule', () => {
 	});
 
 	test('getDiagnostics should return cached diagnostics per document', async () => {
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		const diagnostics = [
 			{
 				code: 'indentation',
@@ -392,7 +398,7 @@ describe('ValidatorModule', () => {
 	});
 
 	test('onDidClose should clear diagnostics', async () => {
-		mockContext.options.validate = ['bar'];
+		mockOptions.validate = ['bar'];
 		const diagnostics = [
 			{
 				code: 'indentation',
@@ -432,37 +438,65 @@ describe('ValidatorModule', () => {
 		expect(mockLogger.debug).toHaveBeenLastCalledWith('Diagnostics cleared', { uri: 'foo' });
 	});
 
-	test("with no debug log level, onDidChangeValidateLanguages shouldn't log languages", () => {
-		mockLogger.isDebugEnabled.mockReturnValue(false);
-		mockContext.documents.all.mockReturnValueOnce([]);
-		const module = new ValidatorModule(getParams(true));
-
-		module.onDidChangeValidateLanguages({
-			languages: new Set(['foo']),
-			removedLanguages: new Set(),
+	test('when the configuration is updated, all documents should be revalidated', async () => {
+		mockOptions.validate = ['baz', 'qux'];
+		mockContext.lintDocument.mockImplementation((document) => {
+			return document.uri === 'foo'
+				? {
+						diagnostics: [
+							{
+								code: 'indentation',
+								message: 'Expected indentation of 4 spaces',
+								range: Range.create(4, 5, 1, 4),
+							},
+						],
+				  }
+				: {
+						diagnostics: [
+							{
+								code: 'color-hex-case',
+								message: 'Expected "#CCC" to be "#ccc"',
+								range: Range.create(2, 2, 2, 6),
+							},
+						],
+				  };
 		});
+		mockContext.documents.all.mockReturnValue([
+			{ uri: 'foo', languageId: 'baz' },
+			{ uri: 'bar', languageId: 'qux' },
+		]);
 
-		expect(mockLogger.debug).not.toHaveBeenCalledWith('Received onDidChangeValidateLanguages');
-		expect(mockLogger.debug).not.toHaveBeenCalledWith('Registering formatter for languages');
-	});
-
-	test('with debug log level, onDidChangeValidateLanguages should log languages', () => {
-		mockLogger.isDebugEnabled.mockReturnValue(true);
-		mockContext.documents.all.mockReturnValueOnce([]);
 		const module = new ValidatorModule(getParams(true));
 
-		module.onDidChangeValidateLanguages({
-			languages: new Set(['foo']),
-			removedLanguages: new Set(),
-		});
+		await module.onDidChangeConfiguration();
 
-		expect(mockLogger.debug).toHaveBeenLastCalledWith('Received onDidChangeValidateLanguages', {
-			removedLanguages: [],
+		expect(mockContext.lintDocument).toHaveBeenCalledTimes(2);
+		expect(mockContext.connection.sendDiagnostics).toHaveBeenCalledTimes(2);
+		expect(mockContext.connection.sendDiagnostics).toHaveBeenCalledWith({
+			uri: 'foo',
+			diagnostics: [
+				{
+					code: 'indentation',
+					message: 'Expected indentation of 4 spaces',
+					range: Range.create(4, 5, 1, 4),
+				},
+			],
 		});
+		expect(mockContext.connection.sendDiagnostics).toHaveBeenCalledWith({
+			uri: 'bar',
+			diagnostics: [
+				{
+					code: 'color-hex-case',
+					message: 'Expected "#CCC" to be "#ccc"',
+					range: Range.create(2, 2, 2, 6),
+				},
+			],
+		});
+		expect(mockLogger.debug).toHaveBeenCalledWith('Received onDidChangeConfiguration');
 	});
 
-	test('when languages are removed, onDidChangeValidateLanguages should clear diagnostics for documents of those languages', async () => {
-		mockContext.options.validate = ['baz', 'qux'];
+	test('when the configuration is updated, documents excluded from new config should have diagnostics cleared', async () => {
+		mockOptions.validate = ['baz', 'qux'];
 		mockContext.lintDocument.mockImplementation((document) => {
 			return document.uri === 'foo'
 				? {
@@ -493,13 +527,12 @@ describe('ValidatorModule', () => {
 
 		await module.onDidChangeConfiguration();
 
-		module.onDidChangeValidateLanguages({
-			languages: new Set(['qux']),
-			removedLanguages: new Set(['baz']),
-		});
+		mockOptions.validate = ['baz'];
 
-		expect(mockContext.lintDocument).toHaveBeenCalledTimes(2);
-		expect(mockContext.connection.sendDiagnostics).toHaveBeenCalledTimes(3);
+		await module.onDidChangeConfiguration();
+
+		expect(mockContext.lintDocument).toHaveBeenCalledTimes(3);
+		expect(mockContext.connection.sendDiagnostics).toHaveBeenCalledTimes(4);
 		expect(mockContext.connection.sendDiagnostics).toHaveBeenCalledWith({
 			uri: 'foo',
 			diagnostics: [
@@ -521,9 +554,15 @@ describe('ValidatorModule', () => {
 			],
 		});
 		expect(mockContext.connection.sendDiagnostics).toHaveBeenCalledWith({
-			uri: 'foo',
+			uri: 'bar',
 			diagnostics: [],
 		});
-		expect(mockLogger.debug).toHaveBeenCalledWith('Diagnostics cleared', { uri: 'foo' });
+		expect(mockLogger.debug).toHaveBeenCalledWith(
+			'Document should not be validated, clearing diagnostics',
+			{
+				uri: 'bar',
+				language: 'qux',
+			},
+		);
 	});
 });
diff --git a/test/e2e/config/__snapshots__/config-basedir.ts.snap b/test/e2e/__tests__/__snapshots__/config-basedir.ts.snap
similarity index 100%
rename from test/e2e/config/__snapshots__/config-basedir.ts.snap
rename to test/e2e/__tests__/__snapshots__/config-basedir.ts.snap
diff --git a/test/e2e/config/__snapshots__/config-file.ts.snap b/test/e2e/__tests__/__snapshots__/config-file.ts.snap
similarity index 100%
rename from test/e2e/config/__snapshots__/config-file.ts.snap
rename to test/e2e/__tests__/__snapshots__/config-file.ts.snap
diff --git a/test/e2e/custom-syntax/__snapshots__/index.ts.snap b/test/e2e/__tests__/__snapshots__/custom-syntax.ts.snap
similarity index 100%
rename from test/e2e/custom-syntax/__snapshots__/index.ts.snap
rename to test/e2e/__tests__/__snapshots__/custom-syntax.ts.snap
diff --git a/test/e2e/defaults/__snapshots__/format.ts.snap b/test/e2e/__tests__/__snapshots__/format.ts.snap
similarity index 100%
rename from test/e2e/defaults/__snapshots__/format.ts.snap
rename to test/e2e/__tests__/__snapshots__/format.ts.snap
diff --git a/test/e2e/ignore-disables/__snapshots__/index.ts.snap b/test/e2e/__tests__/__snapshots__/ignore-disables.ts.snap
similarity index 100%
rename from test/e2e/ignore-disables/__snapshots__/index.ts.snap
rename to test/e2e/__tests__/__snapshots__/ignore-disables.ts.snap
diff --git a/test/e2e/defaults/__snapshots__/lint.ts.snap b/test/e2e/__tests__/__snapshots__/lint.ts.snap
similarity index 100%
rename from test/e2e/defaults/__snapshots__/lint.ts.snap
rename to test/e2e/__tests__/__snapshots__/lint.ts.snap
diff --git a/test/e2e/report-disables/__snapshots__/invalid-scope.ts.snap b/test/e2e/__tests__/__snapshots__/report-invalid-scope-disables.ts.snap
similarity index 100%
rename from test/e2e/report-disables/__snapshots__/invalid-scope.ts.snap
rename to test/e2e/__tests__/__snapshots__/report-invalid-scope-disables.ts.snap
diff --git a/test/e2e/report-disables/__snapshots__/needless.ts.snap b/test/e2e/__tests__/__snapshots__/report-needless-disables.ts.snap
similarity index 100%
rename from test/e2e/report-disables/__snapshots__/needless.ts.snap
rename to test/e2e/__tests__/__snapshots__/report-needless-disables.ts.snap
diff --git a/test/e2e/defaults/__snapshots__/resolve-local.ts.snap b/test/e2e/__tests__/__snapshots__/resolve-local.ts.snap
similarity index 100%
rename from test/e2e/defaults/__snapshots__/resolve-local.ts.snap
rename to test/e2e/__tests__/__snapshots__/resolve-local.ts.snap
diff --git a/test/e2e/stylelint-path/__snapshots__/index.ts.snap b/test/e2e/__tests__/__snapshots__/stylelint-path.ts.snap
similarity index 100%
rename from test/e2e/stylelint-path/__snapshots__/index.ts.snap
rename to test/e2e/__tests__/__snapshots__/stylelint-path.ts.snap
diff --git a/test/e2e/validate/__snapshots__/index.ts.snap b/test/e2e/__tests__/__snapshots__/validate.ts.snap
similarity index 100%
rename from test/e2e/validate/__snapshots__/index.ts.snap
rename to test/e2e/__tests__/__snapshots__/validate.ts.snap
diff --git a/test/e2e/defaults/__snapshots__/yarn-2-pnp.ts.snap b/test/e2e/__tests__/__snapshots__/yarn-2-pnp.ts.snap
similarity index 100%
rename from test/e2e/defaults/__snapshots__/yarn-2-pnp.ts.snap
rename to test/e2e/__tests__/__snapshots__/yarn-2-pnp.ts.snap
diff --git a/test/e2e/defaults/__snapshots__/yarn-pnp.ts.snap b/test/e2e/__tests__/__snapshots__/yarn-pnp.ts.snap
similarity index 100%
rename from test/e2e/defaults/__snapshots__/yarn-pnp.ts.snap
rename to test/e2e/__tests__/__snapshots__/yarn-pnp.ts.snap
diff --git a/test/e2e/config/config-basedir.ts b/test/e2e/__tests__/config-basedir.ts
similarity index 85%
rename from test/e2e/config/config-basedir.ts
rename to test/e2e/__tests__/config-basedir.ts
index 077fbaa..e1432fb 100644
--- a/test/e2e/config/config-basedir.ts
+++ b/test/e2e/__tests__/config-basedir.ts
@@ -4,12 +4,10 @@ import pWaitFor from 'p-wait-for';
 import { normalizeDiagnostic, getStylelintDiagnostics } from '../utils';
 import { workspace, window } from 'vscode';
 
-const workspaceDir = path.join(__dirname, 'workspace');
-
 describe('vscode-stylelint with "stylelint.configBasedir"', () => {
 	it('should work even if "stylelint.configBasedir" is defined', async () => {
 		const cssDocument = await workspace.openTextDocument(
-			path.resolve(workspaceDir, 'config-basedir.css'),
+			path.resolve(workspaceDir, 'config/config-basedir.css'),
 		);
 
 		await window.showTextDocument(cssDocument);
diff --git a/test/e2e/config/config-file.ts b/test/e2e/__tests__/config-file.ts
similarity index 86%
rename from test/e2e/config/config-file.ts
rename to test/e2e/__tests__/config-file.ts
index edec07d..96172f6 100644
--- a/test/e2e/config/config-file.ts
+++ b/test/e2e/__tests__/config-file.ts
@@ -4,12 +4,10 @@ import { workspace, window } from 'vscode';
 
 import { normalizeDiagnostic, getStylelintDiagnostics } from '../utils';
 
-const workspaceDir = path.join(__dirname, 'workspace');
-
 describe('vscode-stylelint with "stylelint.configFile"', () => {
 	it('should work even if "stylelint.configFile" is defined', async () => {
 		const cssDocument = await workspace.openTextDocument(
-			path.resolve(workspaceDir, 'config-file.css'),
+			path.resolve(workspaceDir, 'config/config-file.css'),
 		);
 
 		await window.showTextDocument(cssDocument);
diff --git a/test/e2e/custom-syntax/index.ts b/test/e2e/__tests__/custom-syntax.ts
similarity index 75%
rename from test/e2e/custom-syntax/index.ts
rename to test/e2e/__tests__/custom-syntax.ts
index ac40b71..5d8ae44 100644
--- a/test/e2e/custom-syntax/index.ts
+++ b/test/e2e/__tests__/custom-syntax.ts
@@ -4,12 +4,12 @@ import { workspace, commands, window } from 'vscode';
 
 describe('vscode-stylelint with "stylelint.customSyntax"', () => {
 	it('auto-fix should work if "stylelint.customSyntax" is enabled', async () => {
-		// Open the './test.css' file.
-		const cssDocument = await workspace.openTextDocument(path.resolve(__dirname, 'test.css'));
+		const cssDocument = await workspace.openTextDocument(
+			path.resolve(workspaceDir, 'custom-syntax/test.css'),
+		);
 
 		await window.showTextDocument(cssDocument);
 
-		// Wait until the Auto-fix command is available.
 		await pWaitFor(
 			async () => {
 				const names = await commands.getCommands();
@@ -22,11 +22,9 @@ describe('vscode-stylelint with "stylelint.customSyntax"', () => {
 			{ timeout: 2000 },
 		);
 
-		// Execute the Auto-fix command.
 		// cspell:disable-next-line
 		await commands.executeCommand('stylelint.executeAutofix');
 
-		// Check the result.
 		expect(cssDocument.getText()).toMatchSnapshot();
 	});
 });
diff --git a/test/e2e/__tests__/format.ts b/test/e2e/__tests__/format.ts
new file mode 100644
index 0000000..b4fa3f5
--- /dev/null
+++ b/test/e2e/__tests__/format.ts
@@ -0,0 +1,50 @@
+import path from 'path';
+import { workspace, commands, window, extensions } from 'vscode';
+import { URI } from 'vscode-uri';
+import { PublicApi, ApiEvent } from '../../../src/extension';
+
+describe('Document formatting', () => {
+	it('should format document using formatting options', async () => {
+		const extension = extensions.getExtension('stylelint.vscode-stylelint');
+
+		if (!extension) {
+			throw new Error('Unable to find Stylelint extension');
+		}
+
+		const api = extension.exports as PublicApi;
+
+		// api is an event emitter. Wait for the DidRegisterDocumentFormattingEditProvider
+		// event to be emitted before continuing.
+
+		const documentPath = path.resolve(workspaceDir, 'defaults/format.css');
+
+		const eventPromise = new Promise<void>((resolve, reject) => {
+			api.on(ApiEvent.DidRegisterDocumentFormattingEditProvider, ({ uri }) => {
+				const { fsPath } = URI.parse(uri);
+
+				if (path.relative(documentPath, fsPath) === '') {
+					resolve();
+				}
+			});
+
+			setTimeout(() => {
+				reject(new Error('Timed out waiting for DidRegisterDocumentFormattingEditProvider event'));
+			}, 5000);
+		});
+
+		const cssDocument = await workspace.openTextDocument(
+			path.resolve(workspaceDir, 'defaults/format.css'),
+		);
+
+		const editor = await window.showTextDocument(cssDocument);
+
+		await eventPromise;
+
+		editor.options.tabSize = 4;
+		editor.options.insertSpaces = false;
+
+		await commands.executeCommand('editor.action.formatDocument');
+
+		expect(cssDocument.getText()).toMatchSnapshot();
+	}, 30000);
+});
diff --git a/test/e2e/ignore-disables/index.ts b/test/e2e/__tests__/ignore-disables.ts
similarity index 80%
rename from test/e2e/ignore-disables/index.ts
rename to test/e2e/__tests__/ignore-disables.ts
index a50069e..380f253 100644
--- a/test/e2e/ignore-disables/index.ts
+++ b/test/e2e/__tests__/ignore-disables.ts
@@ -7,14 +7,14 @@ import { normalizeDiagnostic, getStylelintDiagnostics } from '../utils';
 
 describe('vscode-stylelint with "stylelint.ignoreDisables"', () => {
 	it('should work if "stylelint.ignoreDisables" is enabled', async () => {
-		// Open the './test.css' file.
-		const cssDocument = await workspace.openTextDocument(path.resolve(__dirname, 'test.css'));
+		const cssDocument = await workspace.openTextDocument(
+			path.resolve(workspaceDir, 'ignore-disables/test.css'),
+		);
 
 		await window.showTextDocument(cssDocument);
 
 		await pWaitFor(() => getStylelintDiagnostics(cssDocument.uri).length > 0, { timeout: 5000 });
 
-		// Check the result.
 		const diagnostics = getStylelintDiagnostics(cssDocument.uri);
 
 		expect(diagnostics.map(normalizeDiagnostic)).toMatchSnapshot();
diff --git a/test/e2e/defaults/ignore.ts b/test/e2e/__tests__/ignore.ts
similarity index 81%
rename from test/e2e/defaults/ignore.ts
rename to test/e2e/__tests__/ignore.ts
index 7fa74d3..efeb891 100644
--- a/test/e2e/defaults/ignore.ts
+++ b/test/e2e/__tests__/ignore.ts
@@ -6,19 +6,18 @@ import { workspace } from 'vscode';
 
 // import { normalizeDiagnostic, getStylelintDiagnostics } from '../utils';
 
-const workspaceDir = path.join(__dirname, 'workspace');
-
 describe('.stylelintignore', () => {
 	it('should have syntax highlighting', async () => {
 		expect(
-			(await workspace.openTextDocument(path.join(workspaceDir, '.stylelintignore'))).languageId,
+			(await workspace.openTextDocument(path.join(workspaceDir, 'defaults/.stylelintignore')))
+				.languageId,
 		).toBe('ignore');
 	});
 
 	// TODO: Get .stylelintignore to work
 	// eslint-disable-next-line jest/no-commented-out-tests
 	// it('should be respected', async () => {
-	// 	const cssDocument = await workspace.openTextDocument(path.resolve(workspaceDir, 'ignored.css'));
+	// 	const cssDocument = await workspace.openTextDocument(path.resolve(workspaceDir, 'defaults/ignored.css'));
 
 	// 	await window.showTextDocument(cssDocument);
 
diff --git a/test/e2e/defaults/lint.ts b/test/e2e/__tests__/lint.ts
similarity index 82%
rename from test/e2e/defaults/lint.ts
rename to test/e2e/__tests__/lint.ts
index ca6a2d5..0eaab42 100644
--- a/test/e2e/defaults/lint.ts
+++ b/test/e2e/__tests__/lint.ts
@@ -5,11 +5,11 @@ import { workspace, window } from 'vscode';
 
 import { normalizeDiagnostic, getStylelintDiagnostics } from '../utils';
 
-const workspaceDir = path.join(__dirname, 'workspace');
-
 describe('Linting', () => {
 	it('should lint CSS documents', async () => {
-		const cssDocument = await workspace.openTextDocument(path.resolve(workspaceDir, 'lint.css'));
+		const cssDocument = await workspace.openTextDocument(
+			path.resolve(workspaceDir, 'defaults/lint.css'),
+		);
 
 		await window.showTextDocument(cssDocument);
 
@@ -20,7 +20,7 @@ describe('Linting', () => {
 
 	it('should display rule documentation links when one is available', async () => {
 		const cssDocument = await workspace.openTextDocument(
-			path.resolve(workspaceDir, 'rule-doc.css'),
+			path.resolve(workspaceDir, 'defaults/rule-doc.css'),
 		);
 
 		await window.showTextDocument(cssDocument);
diff --git a/test/e2e/report-disables/invalid-scope.ts b/test/e2e/__tests__/report-invalid-scope-disables.ts
similarity index 87%
rename from test/e2e/report-disables/invalid-scope.ts
rename to test/e2e/__tests__/report-invalid-scope-disables.ts
index 69e9d79..ab1d9b5 100644
--- a/test/e2e/report-disables/invalid-scope.ts
+++ b/test/e2e/__tests__/report-invalid-scope-disables.ts
@@ -5,12 +5,10 @@ import { workspace, window } from 'vscode';
 
 import { normalizeDiagnostic, getStylelintDiagnostics } from '../utils';
 
-const workspaceDir = path.join(__dirname, 'workspace');
-
 describe('vscode-stylelint with "stylelint.reportInvalidScopeDisables"', () => {
 	it('should work if "stylelint.reportInvalidScopeDisables" is enabled', async () => {
 		const cssDocument = await workspace.openTextDocument(
-			path.resolve(workspaceDir, 'invalid-scope.css'),
+			path.resolve(workspaceDir, 'report-disables/invalid-scope.css'),
 		);
 
 		await window.showTextDocument(cssDocument);
diff --git a/test/e2e/report-disables/needless.ts b/test/e2e/__tests__/report-needless-disables.ts
similarity index 88%
rename from test/e2e/report-disables/needless.ts
rename to test/e2e/__tests__/report-needless-disables.ts
index 250ffb2..b3e60e9 100644
--- a/test/e2e/report-disables/needless.ts
+++ b/test/e2e/__tests__/report-needless-disables.ts
@@ -5,12 +5,10 @@ import { workspace, window } from 'vscode';
 
 import { normalizeDiagnostic, getStylelintDiagnostics } from '../utils';
 
-const workspaceDir = path.join(__dirname, 'workspace');
-
 describe('vscode-stylelint with "stylelint.reportNeedlessDisables"', () => {
 	it('should work if "stylelint.reportNeedlessDisables" is enabled', async () => {
 		const cssDocument = await workspace.openTextDocument(
-			path.resolve(workspaceDir, 'needless.css'),
+			path.resolve(workspaceDir, 'report-disables/needless.css'),
 		);
 
 		await window.showTextDocument(cssDocument);
diff --git a/test/e2e/defaults/resolve-local.ts b/test/e2e/__tests__/resolve-local.ts
similarity index 79%
rename from test/e2e/defaults/resolve-local.ts
rename to test/e2e/__tests__/resolve-local.ts
index c9d033d..c6ab2b8 100644
--- a/test/e2e/defaults/resolve-local.ts
+++ b/test/e2e/__tests__/resolve-local.ts
@@ -5,11 +5,11 @@ import { workspace, window } from 'vscode';
 
 import { normalizeDiagnostic, getStylelintDiagnostics } from '../utils';
 
-const workspaceDir = path.join(__dirname, 'workspace/local-stylelint');
-
 describe('Local Stylelint resolution', () => {
 	it('should resolve to the locally installed copy of Stylelint', async () => {
-		const cssDocument = await workspace.openTextDocument(path.resolve(workspaceDir, 'test.css'));
+		const cssDocument = await workspace.openTextDocument(
+			path.resolve(workspaceDir, 'defaults/local-stylelint/test.css'),
+		);
 
 		await window.showTextDocument(cssDocument);
 
diff --git a/test/e2e/stylelint-path/index.ts b/test/e2e/__tests__/stylelint-path.ts
similarity index 79%
rename from test/e2e/stylelint-path/index.ts
rename to test/e2e/__tests__/stylelint-path.ts
index fb152fa..ea3a92c 100644
--- a/test/e2e/stylelint-path/index.ts
+++ b/test/e2e/__tests__/stylelint-path.ts
@@ -7,12 +7,12 @@ import { normalizeDiagnostic, getStylelintDiagnostics } from '../utils';
 
 describe('vscode-stylelint with "stylelint.stylelintPath"', () => {
 	it('should work even if "stylelint.stylelintPath" is defined', async () => {
-		// Open the './test.css' file.
-		const cssDocument = await workspace.openTextDocument(path.resolve(__dirname, 'test.css'));
+		const cssDocument = await workspace.openTextDocument(
+			path.resolve(workspaceDir, 'stylelint-path/test.css'),
+		);
 
 		await window.showTextDocument(cssDocument);
 
-		// Wait for diagnostics result.
 		await pWaitFor(() => getStylelintDiagnostics(cssDocument.uri).length > 0, { timeout: 5000 });
 
 		// Check the result.
diff --git a/test/e2e/validate/index.ts b/test/e2e/__tests__/validate.ts
similarity index 70%
rename from test/e2e/validate/index.ts
rename to test/e2e/__tests__/validate.ts
index e8b247c..3c8aed3 100644
--- a/test/e2e/validate/index.ts
+++ b/test/e2e/__tests__/validate.ts
@@ -21,62 +21,53 @@ describe('vscode-stylelint with "stylelint.validate" set to ["scss"]', () => {
 	});
 
 	it("shouldn't lint or fix css", async () => {
-		// Open the './test.css' file.
-		const cssDocument = await workspace.openTextDocument(path.resolve(__dirname, 'test.css'));
+		const cssDocument = await workspace.openTextDocument(
+			path.resolve(workspaceDir, 'validate/test.css'),
+		);
 
 		await window.showTextDocument(cssDocument);
 
-		// Wait for diagnostics result.
 		await new Promise((resolve) => setTimeout(resolve, 2000));
 
-		// Check the result.
 		expect(getStylelintDiagnostics(cssDocument.uri)).toEqual([]);
 
-		// Execute the Auto-fix command.
 		// cspell:disable-next-line
 		await commands.executeCommand('stylelint.executeAutofix');
 
-		// Check the result.
 		expect(cssDocument.getText()).toMatchSnapshot();
 	});
 
 	it('should lint and auto-fix scss', async () => {
-		// Open the './test.scss' file.
-		const scssDocument = await workspace.openTextDocument(path.resolve(__dirname, 'test.scss'));
+		const scssDocument = await workspace.openTextDocument(
+			path.resolve(workspaceDir, 'validate/test.scss'),
+		);
 
 		await window.showTextDocument(scssDocument);
 
-		// Wait for diagnostics result.
 		await pWaitFor(() => getStylelintDiagnostics(scssDocument.uri).length > 0, { timeout: 5000 });
 
-		// Check the result.
 		expect(getStylelintDiagnostics(scssDocument.uri).map(normalizeDiagnostic)).toMatchSnapshot();
 
-		// Execute the Auto-fix command.
 		// cspell:disable-next-line
 		await commands.executeCommand('stylelint.executeAutofix');
 
-		// Check the result.
 		expect(scssDocument.getText()).toMatchSnapshot();
 	});
 
 	it("shouldn't lint or fix markdown", async () => {
-		// Open the './test.md' file.
-		const mdDocument = await workspace.openTextDocument(path.resolve(__dirname, 'test.md'));
+		const mdDocument = await workspace.openTextDocument(
+			path.resolve(workspaceDir, 'validate/test.md'),
+		);
 
 		await window.showTextDocument(mdDocument);
 
-		// Wait for diagnostics result.
 		await new Promise((resolve) => setTimeout(resolve, 2000));
 
-		// Check the result.
 		expect(getStylelintDiagnostics(mdDocument.uri)).toEqual([]);
 
-		// Execute the Auto-fix command.
 		// cspell:disable-next-line
 		await commands.executeCommand('stylelint.executeAutofix');
 
-		// Check the result.
 		expect(mdDocument.getText()).toMatchSnapshot();
 	});
 });
diff --git a/test/e2e/defaults/yarn-2-pnp.ts b/test/e2e/__tests__/yarn-2-pnp.ts
similarity index 77%
rename from test/e2e/defaults/yarn-2-pnp.ts
rename to test/e2e/__tests__/yarn-2-pnp.ts
index 538c690..f751dde 100644
--- a/test/e2e/defaults/yarn-2-pnp.ts
+++ b/test/e2e/__tests__/yarn-2-pnp.ts
@@ -5,11 +5,11 @@ import { workspace, window } from 'vscode';
 
 import { normalizeDiagnostic, getStylelintDiagnostics } from '../utils';
 
-const workspaceDir = path.join(__dirname, 'workspace/yarn-2-pnp');
-
 describe('Yarn 2.x PnP Stylelint resolution', () => {
 	it('should resolve Stylelint using PnP', async () => {
-		const cssDocument = await workspace.openTextDocument(path.resolve(workspaceDir, 'test.css'));
+		const cssDocument = await workspace.openTextDocument(
+			path.resolve(workspaceDir, 'defaults/yarn-2-pnp/test.css'),
+		);
 
 		await window.showTextDocument(cssDocument);
 
diff --git a/test/e2e/defaults/yarn-pnp.ts b/test/e2e/__tests__/yarn-pnp.ts
similarity index 77%
rename from test/e2e/defaults/yarn-pnp.ts
rename to test/e2e/__tests__/yarn-pnp.ts
index 1bc7a0d..25b313c 100644
--- a/test/e2e/defaults/yarn-pnp.ts
+++ b/test/e2e/__tests__/yarn-pnp.ts
@@ -5,11 +5,11 @@ import { workspace, window } from 'vscode';
 
 import { normalizeDiagnostic, getStylelintDiagnostics } from '../utils';
 
-const workspaceDir = path.join(__dirname, 'workspace/yarn-pnp');
-
 describe('PnP Stylelint resolution', () => {
 	it('should resolve Stylelint using PnP', async () => {
-		const cssDocument = await workspace.openTextDocument(path.resolve(workspaceDir, 'test.css'));
+		const cssDocument = await workspace.openTextDocument(
+			path.resolve(workspaceDir, 'defaults/yarn-pnp/test.css'),
+		);
 
 		await window.showTextDocument(cssDocument);
 
diff --git a/test/e2e/config/jest-runner-vscode.config.js b/test/e2e/config/jest-runner-vscode.config.js
deleted file mode 100644
index ae4b8a5..0000000
--- a/test/e2e/config/jest-runner-vscode.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-'use strict';
-
-const path = require('path');
-
-/** @type {import('jest-runner-vscode').RunnerOptions} */
-const config = {
-	workspaceDir: path.join(__dirname, 'workspace'),
-};
-
-module.exports = config;
diff --git a/test/e2e/custom-syntax/custom-syntax.js b/test/e2e/custom-syntax/custom-syntax.js
deleted file mode 100644
index e99b9f6..0000000
--- a/test/e2e/custom-syntax/custom-syntax.js
+++ /dev/null
@@ -1,4 +0,0 @@
-'use strict';
-
-//@ts-expect-error - no type definitions
-module.exports = require('../../../node_modules/postcss-sass');
diff --git a/test/e2e/defaults/format.ts b/test/e2e/defaults/format.ts
deleted file mode 100644
index e4f9ce8..0000000
--- a/test/e2e/defaults/format.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import path from 'path';
-import pWaitFor from 'p-wait-for';
-import { workspace, commands, window, extensions } from 'vscode';
-
-const workspaceDir = path.join(__dirname, 'workspace');
-
-describe('Document formatting', () => {
-	beforeAll(async () => {
-		const extension = extensions.getExtension('stylelint.vscode-stylelint');
-
-		if (!extension) {
-			throw new Error('Unable to find Stylelint extension');
-		}
-
-		const api = /** @type {ExtensionPublicApi} */ extension.exports;
-
-		await pWaitFor(() => api.formattingReady);
-	});
-
-	it('should format document using formatting options', async () => {
-		const cssDocument = await workspace.openTextDocument(path.resolve(workspaceDir, 'format.css'));
-
-		const editor = await window.showTextDocument(cssDocument);
-
-		editor.options.tabSize = 4;
-		editor.options.insertSpaces = false;
-
-		await commands.executeCommand('editor.action.formatDocument');
-
-		expect(cssDocument.getText()).toMatchSnapshot();
-	}, 30000);
-});
diff --git a/test/e2e/defaults/jest-runner-vscode.config.js b/test/e2e/defaults/jest-runner-vscode.config.js
deleted file mode 100644
index fee83b0..0000000
--- a/test/e2e/defaults/jest-runner-vscode.config.js
+++ /dev/null
@@ -1,8 +0,0 @@
-'use strict';
-
-/** @type {import('jest-runner-vscode').RunnerOptions} */
-const config = {
-	workspaceDir: './workspace',
-};
-
-module.exports = config;
diff --git a/test/e2e/jest.config.js b/test/e2e/jest.config.js
index a0adaf5..e9e5fa1 100644
--- a/test/e2e/jest.config.js
+++ b/test/e2e/jest.config.js
@@ -2,20 +2,15 @@
 
 /** @type {import('@jest/types').Config.InitialOptions} */
 const config = {
-	testMatch: [
-		'<rootDir>/*/index.[jt]s?(x)',
-		'<rootDir>/defaults/*.[jt]s?(x)',
-		'<rootDir>/config/*.[jt]s?(x)',
-		'<rootDir>/report-disables/*.[jt]s?(x)',
-	],
+	testMatch: ['<rootDir>/__tests__/**/*.[jt]s?(x)'],
 	preset: 'ts-jest',
 	globals: { 'ts-jest': { tsconfig: '<rootDir>/../../tsconfig.test.json' } },
 	testPathIgnorePatterns: ['.*jest-runner-vscode.config.js'],
 	verbose: true,
 	modulePathIgnorePatterns: [
 		'<rootDir>/../../.vscode-test',
-		'<rootDir>/defaults/workspace/yarn-[^/]+/stylelint',
-		'<rootDir>/defaults/workspace/local-stylelint/node_modules',
+		'<rootDir>/workspace/defaults/yarn-[^/]+/stylelint',
+		'<rootDir>/workspace/defaults/local-stylelint/node_modules',
 	],
 	setupFilesAfterEnv: ['<rootDir>/setup.ts'],
 	runner: 'vscode',
diff --git a/test/e2e/report-disables/jest-runner-vscode.config.js b/test/e2e/report-disables/jest-runner-vscode.config.js
deleted file mode 100644
index ae4b8a5..0000000
--- a/test/e2e/report-disables/jest-runner-vscode.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-'use strict';
-
-const path = require('path');
-
-/** @type {import('jest-runner-vscode').RunnerOptions} */
-const config = {
-	workspaceDir: path.join(__dirname, 'workspace'),
-};
-
-module.exports = config;
diff --git a/test/e2e/setup.ts b/test/e2e/setup.ts
index 39d6c0e..9fa8590 100644
--- a/test/e2e/setup.ts
+++ b/test/e2e/setup.ts
@@ -1,4 +1,5 @@
 import pWaitFor from 'p-wait-for';
+import path from 'path';
 
 import { extensions } from 'vscode';
 
@@ -11,3 +12,9 @@ global.beforeAll(async () => {
 
 	await pWaitFor(() => vscodeStylelint.isActive, { timeout: 2000 });
 });
+
+Object.defineProperty(global, 'workspaceDir', {
+	get() {
+		return path.join(__dirname, 'workspace');
+	},
+});
diff --git a/test/e2e/config/workspace/.vscode/settings.json b/test/e2e/workspace/config/.vscode/settings.json
similarity index 100%
rename from test/e2e/config/workspace/.vscode/settings.json
rename to test/e2e/workspace/config/.vscode/settings.json
diff --git a/test/e2e/config/workspace/basedir/stylelint-config1.js b/test/e2e/workspace/config/basedir/stylelint-config1.js
similarity index 100%
rename from test/e2e/config/workspace/basedir/stylelint-config1.js
rename to test/e2e/workspace/config/basedir/stylelint-config1.js
diff --git a/test/e2e/config/workspace/basedir/stylelint-config2.js b/test/e2e/workspace/config/basedir/stylelint-config2.js
similarity index 100%
rename from test/e2e/config/workspace/basedir/stylelint-config2.js
rename to test/e2e/workspace/config/basedir/stylelint-config2.js
diff --git a/test/e2e/config/workspace/config-basedir.css b/test/e2e/workspace/config/config-basedir.css
similarity index 100%
rename from test/e2e/config/workspace/config-basedir.css
rename to test/e2e/workspace/config/config-basedir.css
diff --git a/test/e2e/config/workspace/config-file.css b/test/e2e/workspace/config/config-file.css
similarity index 100%
rename from test/e2e/config/workspace/config-file.css
rename to test/e2e/workspace/config/config-file.css
diff --git a/test/e2e/config/workspace/config/stylelint.config.js b/test/e2e/workspace/config/config/stylelint.config.js
similarity index 100%
rename from test/e2e/config/workspace/config/stylelint.config.js
rename to test/e2e/workspace/config/config/stylelint.config.js
diff --git a/test/e2e/custom-syntax/.vscode/settings.json b/test/e2e/workspace/custom-syntax/.vscode/settings.json
similarity index 100%
rename from test/e2e/custom-syntax/.vscode/settings.json
rename to test/e2e/workspace/custom-syntax/.vscode/settings.json
diff --git a/test/e2e/workspace/custom-syntax/custom-syntax.js b/test/e2e/workspace/custom-syntax/custom-syntax.js
new file mode 100644
index 0000000..f3f4112
--- /dev/null
+++ b/test/e2e/workspace/custom-syntax/custom-syntax.js
@@ -0,0 +1,4 @@
+'use strict';
+
+//@ts-expect-error - no type definitions
+module.exports = require('../../../../node_modules/postcss-sass');
diff --git a/test/e2e/custom-syntax/stylelint.config.js b/test/e2e/workspace/custom-syntax/stylelint.config.js
similarity index 100%
rename from test/e2e/custom-syntax/stylelint.config.js
rename to test/e2e/workspace/custom-syntax/stylelint.config.js
diff --git a/test/e2e/custom-syntax/test.css b/test/e2e/workspace/custom-syntax/test.css
similarity index 100%
rename from test/e2e/custom-syntax/test.css
rename to test/e2e/workspace/custom-syntax/test.css
diff --git a/test/e2e/defaults/workspace/.stylelintignore b/test/e2e/workspace/defaults/.stylelintignore
similarity index 100%
rename from test/e2e/defaults/workspace/.stylelintignore
rename to test/e2e/workspace/defaults/.stylelintignore
diff --git a/test/e2e/defaults/workspace/.vscode/settings.json b/test/e2e/workspace/defaults/.vscode/settings.json
similarity index 100%
rename from test/e2e/defaults/workspace/.vscode/settings.json
rename to test/e2e/workspace/defaults/.vscode/settings.json
diff --git a/test/e2e/defaults/workspace/format.css b/test/e2e/workspace/defaults/format.css
similarity index 100%
rename from test/e2e/defaults/workspace/format.css
rename to test/e2e/workspace/defaults/format.css
diff --git a/test/e2e/defaults/workspace/ignored.css b/test/e2e/workspace/defaults/ignored.css
similarity index 100%
rename from test/e2e/defaults/workspace/ignored.css
rename to test/e2e/workspace/defaults/ignored.css
diff --git a/test/e2e/defaults/workspace/lint.css b/test/e2e/workspace/defaults/lint.css
similarity index 100%
rename from test/e2e/defaults/workspace/lint.css
rename to test/e2e/workspace/defaults/lint.css
diff --git a/test/e2e/defaults/workspace/local-stylelint/.gitignore b/test/e2e/workspace/defaults/local-stylelint/.gitignore
similarity index 100%
rename from test/e2e/defaults/workspace/local-stylelint/.gitignore
rename to test/e2e/workspace/defaults/local-stylelint/.gitignore
diff --git a/test/e2e/defaults/workspace/local-stylelint/node_modules/stylelint/index.js b/test/e2e/workspace/defaults/local-stylelint/node_modules/stylelint/index.js
similarity index 100%
rename from test/e2e/defaults/workspace/local-stylelint/node_modules/stylelint/index.js
rename to test/e2e/workspace/defaults/local-stylelint/node_modules/stylelint/index.js
diff --git a/test/e2e/defaults/workspace/local-stylelint/node_modules/stylelint/package.json b/test/e2e/workspace/defaults/local-stylelint/node_modules/stylelint/package.json
similarity index 100%
rename from test/e2e/defaults/workspace/local-stylelint/node_modules/stylelint/package.json
rename to test/e2e/workspace/defaults/local-stylelint/node_modules/stylelint/package.json
diff --git a/test/e2e/defaults/workspace/local-stylelint/test.css b/test/e2e/workspace/defaults/local-stylelint/test.css
similarity index 100%
rename from test/e2e/defaults/workspace/local-stylelint/test.css
rename to test/e2e/workspace/defaults/local-stylelint/test.css
diff --git a/test/e2e/defaults/workspace/rule-doc.css b/test/e2e/workspace/defaults/rule-doc.css
similarity index 100%
rename from test/e2e/defaults/workspace/rule-doc.css
rename to test/e2e/workspace/defaults/rule-doc.css
diff --git a/test/e2e/defaults/workspace/stylelint.config.js b/test/e2e/workspace/defaults/stylelint.config.js
similarity index 100%
rename from test/e2e/defaults/workspace/stylelint.config.js
rename to test/e2e/workspace/defaults/stylelint.config.js
diff --git a/test/e2e/defaults/workspace/test-plugin.js b/test/e2e/workspace/defaults/test-plugin.js
similarity index 100%
rename from test/e2e/defaults/workspace/test-plugin.js
rename to test/e2e/workspace/defaults/test-plugin.js
diff --git a/test/e2e/defaults/workspace/yarn-2-pnp/.pnp.js b/test/e2e/workspace/defaults/yarn-2-pnp/.pnp.js
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-2-pnp/.pnp.js
rename to test/e2e/workspace/defaults/yarn-2-pnp/.pnp.js
diff --git a/test/e2e/defaults/workspace/yarn-2-pnp/.yarn/install-state.gz b/test/e2e/workspace/defaults/yarn-2-pnp/.yarn/install-state.gz
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-2-pnp/.yarn/install-state.gz
rename to test/e2e/workspace/defaults/yarn-2-pnp/.yarn/install-state.gz
diff --git a/test/e2e/defaults/workspace/yarn-2-pnp/.yarn/releases/yarn-2.4.3.cjs b/test/e2e/workspace/defaults/yarn-2-pnp/.yarn/releases/yarn-2.4.3.cjs
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-2-pnp/.yarn/releases/yarn-2.4.3.cjs
rename to test/e2e/workspace/defaults/yarn-2-pnp/.yarn/releases/yarn-2.4.3.cjs
diff --git a/test/e2e/defaults/workspace/yarn-2-pnp/.yarnrc.yml b/test/e2e/workspace/defaults/yarn-2-pnp/.yarnrc.yml
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-2-pnp/.yarnrc.yml
rename to test/e2e/workspace/defaults/yarn-2-pnp/.yarnrc.yml
diff --git a/test/e2e/defaults/workspace/yarn-2-pnp/package.json b/test/e2e/workspace/defaults/yarn-2-pnp/package.json
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-2-pnp/package.json
rename to test/e2e/workspace/defaults/yarn-2-pnp/package.json
diff --git a/test/e2e/defaults/workspace/yarn-2-pnp/stylelint/index.js b/test/e2e/workspace/defaults/yarn-2-pnp/stylelint/index.js
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-2-pnp/stylelint/index.js
rename to test/e2e/workspace/defaults/yarn-2-pnp/stylelint/index.js
diff --git a/test/e2e/defaults/workspace/yarn-2-pnp/stylelint/package.json b/test/e2e/workspace/defaults/yarn-2-pnp/stylelint/package.json
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-2-pnp/stylelint/package.json
rename to test/e2e/workspace/defaults/yarn-2-pnp/stylelint/package.json
diff --git a/test/e2e/defaults/workspace/yarn-2-pnp/test.css b/test/e2e/workspace/defaults/yarn-2-pnp/test.css
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-2-pnp/test.css
rename to test/e2e/workspace/defaults/yarn-2-pnp/test.css
diff --git a/test/e2e/defaults/workspace/yarn-2-pnp/yarn.lock b/test/e2e/workspace/defaults/yarn-2-pnp/yarn.lock
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-2-pnp/yarn.lock
rename to test/e2e/workspace/defaults/yarn-2-pnp/yarn.lock
diff --git a/test/e2e/defaults/workspace/yarn-pnp/.pnp.cjs b/test/e2e/workspace/defaults/yarn-pnp/.pnp.cjs
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-pnp/.pnp.cjs
rename to test/e2e/workspace/defaults/yarn-pnp/.pnp.cjs
diff --git a/test/e2e/defaults/workspace/yarn-pnp/.yarn/install-state.gz b/test/e2e/workspace/defaults/yarn-pnp/.yarn/install-state.gz
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-pnp/.yarn/install-state.gz
rename to test/e2e/workspace/defaults/yarn-pnp/.yarn/install-state.gz
diff --git a/test/e2e/defaults/workspace/yarn-pnp/.yarn/releases/yarn-3.0.2.cjs b/test/e2e/workspace/defaults/yarn-pnp/.yarn/releases/yarn-3.0.2.cjs
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-pnp/.yarn/releases/yarn-3.0.2.cjs
rename to test/e2e/workspace/defaults/yarn-pnp/.yarn/releases/yarn-3.0.2.cjs
diff --git a/test/e2e/defaults/workspace/yarn-pnp/.yarnrc.yml b/test/e2e/workspace/defaults/yarn-pnp/.yarnrc.yml
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-pnp/.yarnrc.yml
rename to test/e2e/workspace/defaults/yarn-pnp/.yarnrc.yml
diff --git a/test/e2e/defaults/workspace/yarn-pnp/package.json b/test/e2e/workspace/defaults/yarn-pnp/package.json
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-pnp/package.json
rename to test/e2e/workspace/defaults/yarn-pnp/package.json
diff --git a/test/e2e/defaults/workspace/yarn-pnp/stylelint/index.js b/test/e2e/workspace/defaults/yarn-pnp/stylelint/index.js
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-pnp/stylelint/index.js
rename to test/e2e/workspace/defaults/yarn-pnp/stylelint/index.js
diff --git a/test/e2e/defaults/workspace/yarn-pnp/stylelint/package.json b/test/e2e/workspace/defaults/yarn-pnp/stylelint/package.json
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-pnp/stylelint/package.json
rename to test/e2e/workspace/defaults/yarn-pnp/stylelint/package.json
diff --git a/test/e2e/defaults/workspace/yarn-pnp/test.css b/test/e2e/workspace/defaults/yarn-pnp/test.css
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-pnp/test.css
rename to test/e2e/workspace/defaults/yarn-pnp/test.css
diff --git a/test/e2e/defaults/workspace/yarn-pnp/yarn.lock b/test/e2e/workspace/defaults/yarn-pnp/yarn.lock
similarity index 100%
rename from test/e2e/defaults/workspace/yarn-pnp/yarn.lock
rename to test/e2e/workspace/defaults/yarn-pnp/yarn.lock
diff --git a/test/e2e/ignore-disables/.vscode/settings.json b/test/e2e/workspace/ignore-disables/.vscode/settings.json
similarity index 100%
rename from test/e2e/ignore-disables/.vscode/settings.json
rename to test/e2e/workspace/ignore-disables/.vscode/settings.json
diff --git a/test/e2e/ignore-disables/stylelint.config.js b/test/e2e/workspace/ignore-disables/stylelint.config.js
similarity index 100%
rename from test/e2e/ignore-disables/stylelint.config.js
rename to test/e2e/workspace/ignore-disables/stylelint.config.js
diff --git a/test/e2e/ignore-disables/test.css b/test/e2e/workspace/ignore-disables/test.css
similarity index 100%
rename from test/e2e/ignore-disables/test.css
rename to test/e2e/workspace/ignore-disables/test.css
diff --git a/test/e2e/report-disables/workspace/.vscode/settings.json b/test/e2e/workspace/report-disables/.vscode/settings.json
similarity index 100%
rename from test/e2e/report-disables/workspace/.vscode/settings.json
rename to test/e2e/workspace/report-disables/.vscode/settings.json
diff --git a/test/e2e/report-disables/workspace/invalid-scope.css b/test/e2e/workspace/report-disables/invalid-scope.css
similarity index 100%
rename from test/e2e/report-disables/workspace/invalid-scope.css
rename to test/e2e/workspace/report-disables/invalid-scope.css
diff --git a/test/e2e/report-disables/workspace/needless.css b/test/e2e/workspace/report-disables/needless.css
similarity index 100%
rename from test/e2e/report-disables/workspace/needless.css
rename to test/e2e/workspace/report-disables/needless.css
diff --git a/test/e2e/report-disables/workspace/stylelint.config.js b/test/e2e/workspace/report-disables/stylelint.config.js
similarity index 100%
rename from test/e2e/report-disables/workspace/stylelint.config.js
rename to test/e2e/workspace/report-disables/stylelint.config.js
diff --git a/test/e2e/stylelint-path/.vscode/settings.json b/test/e2e/workspace/stylelint-path/.vscode/settings.json
similarity index 100%
rename from test/e2e/stylelint-path/.vscode/settings.json
rename to test/e2e/workspace/stylelint-path/.vscode/settings.json
diff --git a/test/e2e/stylelint-path/fake-stylelint.js b/test/e2e/workspace/stylelint-path/fake-stylelint.js
similarity index 100%
rename from test/e2e/stylelint-path/fake-stylelint.js
rename to test/e2e/workspace/stylelint-path/fake-stylelint.js
diff --git a/test/e2e/stylelint-path/stylelint.config.js b/test/e2e/workspace/stylelint-path/stylelint.config.js
similarity index 100%
rename from test/e2e/stylelint-path/stylelint.config.js
rename to test/e2e/workspace/stylelint-path/stylelint.config.js
diff --git a/test/e2e/stylelint-path/test.css b/test/e2e/workspace/stylelint-path/test.css
similarity index 100%
rename from test/e2e/stylelint-path/test.css
rename to test/e2e/workspace/stylelint-path/test.css
diff --git a/test/e2e/validate/.vscode/settings.json b/test/e2e/workspace/validate/.vscode/settings.json
similarity index 100%
rename from test/e2e/validate/.vscode/settings.json
rename to test/e2e/workspace/validate/.vscode/settings.json
diff --git a/test/e2e/validate/stylelint.config.js b/test/e2e/workspace/validate/stylelint.config.js
similarity index 100%
rename from test/e2e/validate/stylelint.config.js
rename to test/e2e/workspace/validate/stylelint.config.js
diff --git a/test/e2e/validate/test.css b/test/e2e/workspace/validate/test.css
similarity index 100%
rename from test/e2e/validate/test.css
rename to test/e2e/workspace/validate/test.css
diff --git a/test/e2e/validate/test.md b/test/e2e/workspace/validate/test.md
similarity index 100%
rename from test/e2e/validate/test.md
rename to test/e2e/workspace/validate/test.md
diff --git a/test/e2e/validate/test.scss b/test/e2e/workspace/validate/test.scss
similarity index 100%
rename from test/e2e/validate/test.scss
rename to test/e2e/workspace/validate/test.scss
diff --git a/test/e2e/workspace/workspace.code-workspace b/test/e2e/workspace/workspace.code-workspace
new file mode 100644
index 0000000..abe6fc3
--- /dev/null
+++ b/test/e2e/workspace/workspace.code-workspace
@@ -0,0 +1,11 @@
+{
+	"folders": [
+		{ "path": "config" },
+		{ "path": "custom-syntax" },
+		{ "path": "defaults" },
+		{ "path": "ignore-disables" },
+		{ "path": "report-disables" },
+		{ "path": "stylelint-path" },
+		{ "path": "validate" }
+	]
+}
diff --git a/test/integration/jest.config.js b/test/integration/jest.config.js
index 3f55add..ea3e97c 100644
--- a/test/integration/jest.config.js
+++ b/test/integration/jest.config.js
@@ -8,8 +8,8 @@ const config = {
 	verbose: true,
 	modulePathIgnorePatterns: [
 		'<rootDir>/../../.vscode-test',
-		'<rootDir>/../e2e/defaults/workspace/yarn-[^/]+/stylelint',
-		'<rootDir>/../e2e/defaults/workspace/local-stylelint/node_modules',
+		'<rootDir>/../e2e/workspace/defaults/yarn-[^/]+/stylelint',
+		'<rootDir>/../e2e/workspace/defaults/local-stylelint/node_modules',
 	],
 };
 
diff --git a/test/types.d.ts b/test/types.d.ts
index 2e536b3..02163a4 100644
--- a/test/types.d.ts
+++ b/test/types.d.ts
@@ -10,6 +10,11 @@ import type { OptionalExcept } from '../src/utils/types';
 import type { TracerFn } from '../src/utils/packages';
 
 declare global {
+	/**
+	 * The test workspace directory.
+	 */
+	const workspaceDir: string;
+
 	/**
 	 * Types used in tests.
 	 */
diff --git a/test/unit/jest.config.js b/test/unit/jest.config.js
index af84901..6394ebc 100644
--- a/test/unit/jest.config.js
+++ b/test/unit/jest.config.js
@@ -9,8 +9,8 @@ const config = {
 	verbose: true,
 	modulePathIgnorePatterns: [
 		'<rootDir>/.vscode-test',
-		'<rootDir>/test/e2e/defaults/workspace/yarn-[^/]+/stylelint',
-		'<rootDir>/test/e2e/defaults/workspace/local-stylelint/node_modules',
+		'<rootDir>/test/e2e/workspace/defaults/yarn-[^/]+/stylelint',
+		'<rootDir>/test/e2e/workspace/defaults/local-stylelint/node_modules',
 	],
 	collectCoverage: true,
 	coverageThreshold: {