Repository: stylelint/vscode-stylelint (ts)

Issue:
[Feature]: Add code actions to auto-fix individual problems
### What problem does this feature solve?

Currently, users can't selectively auto-fix problems. They must instead either auto-fix all problems or manually fix the issue.

### How does this feature solve the problem?

This would allow users to auto-fix only the problems they want to be fixed.

### What are some alternatives to this feature?

_No response_

### Code of Conduct

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

Pull request:
feat: add support for quick fixes
<!-- Each pull request must be associated with an open issue unless it's a documentation fix. If a corresponding issue does not exist, please create one so we can discuss the change first. -->

<!-- Please answer the following. We close pull requests that don't. -->

> Which issue, if any, is this issue related to?

Closes #335

> Is there anything in the PR that needs further explanation?

This PR adds support for quick fixes.

~~There is no test code yet.~~
~~First we need to fix it so that the tests pass with stylelint v16.~~


Patch:
diff --git a/src/server/modules/code-action.ts b/src/server/modules/code-action.ts
index 3d36996..7daf427 100644
--- a/src/server/modules/code-action.ts
+++ b/src/server/modules/code-action.ts
@@ -151,6 +151,27 @@ export class CodeActionModule implements LanguageServerModule {
 			: undefined;
 	}
 
+	#getQuickFixActions(document: TextDocument, context: LSP.CodeActionContext): LSP.CodeAction[] {
+		const identifier = { uri: document.uri, version: document.version };
+		const actions: LSP.CodeAction[] = [];
+
+		for (const diagnostic of context.diagnostics) {
+			const edit = this.#context.getEditInfo(document, diagnostic);
+
+			if (edit) {
+				const action = LSP.CodeAction.create(
+					edit.label,
+					{ documentChanges: [LSP.TextDocumentEdit.create(identifier, [edit.edit])] },
+					LSP.CodeActionKind.QuickFix,
+				);
+
+				actions.push(action);
+			}
+		}
+
+		return actions;
+	}
+
 	#getAutoFixAllCommandAction(document: TextDocument): LSP.CodeAction {
 		const command = LSP.Command.create(
 			'Fix all Stylelint auto-fixable problems',
@@ -263,6 +284,6 @@ export class CodeActionModule implements LanguageServerModule {
 			}
 		}
 
-		return [...actions];
+		return [...this.#getQuickFixActions(document, context), ...actions];
 	}
 }
diff --git a/src/server/server.ts b/src/server/server.ts
index 239e49d..619a4dc 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -12,7 +12,7 @@ import {
 import type stylelint from 'stylelint';
 import type winston from 'winston';
 
-import { getFixes } from '../utils/documents/index';
+import { getFixes, getEditInfo } from '../utils/documents/index';
 import { displayError, CommandManager, NotificationManager } from '../utils/lsp/index';
 import { mergeAssign, mergeOptionsWithDefaults } from '../utils/objects/index';
 import { StylelintRunner, LintDiagnostics } from '../utils/stylelint/index';
@@ -130,6 +130,11 @@ export class StylelintLanguageServer implements Disposable {
 	 */
 	#disposables: LSP.Disposable[] = [];
 
+	/**
+	 * Lint results.
+	 */
+	#lintResults = new Map<string, LintDiagnostics & { version: number }>();
+
 	/**
 	 * Creates a new Stylelint language server.
 	 */
@@ -155,6 +160,7 @@ export class StylelintLanguageServer implements Disposable {
 			getFixes: this.#getFixes.bind(this),
 			displayError: this.#displayError.bind(this),
 			lintDocument: this.#lintDocument.bind(this),
+			getEditInfo: this.#getEditInfo.bind(this),
 			resolveStylelint: this.#resolveStylelint.bind(this),
 		};
 
@@ -361,6 +367,8 @@ export class StylelintLanguageServer implements Disposable {
 
 			const results = await this.#runner.lintDocument(document, linterOptions, options);
 
+			this.#lintResults.set(document.uri, { version: document.version, ...results });
+
 			this.#logger.debug('Lint run complete', { uri: document.uri, results });
 
 			return results;
@@ -375,6 +383,13 @@ export class StylelintLanguageServer implements Disposable {
 		}
 	}
 
+	#getEditInfo(
+		document: TextDocument,
+		diagnostic: LSP.Diagnostic,
+	): { label: string; edit: TextEdit } | undefined {
+		return getEditInfo(document, diagnostic, this.#lintResults.get(document.uri));
+	}
+
 	/**
 	 * Gets text edits for fixes made by Stylelint.
 	 */
@@ -529,6 +544,7 @@ export class StylelintLanguageServer implements Disposable {
 		});
 
 		this.#scopedOptions.delete(document.uri);
+		this.#lintResults.delete(document.uri);
 	}
 
 	async #onDidChangeConfiguration(params: LSP.DidChangeConfigurationParams): Promise<void> {
diff --git a/src/server/types.ts b/src/server/types.ts
index 58b908b..5d72798 100644
--- a/src/server/types.ts
+++ b/src/server/types.ts
@@ -117,6 +117,16 @@ export interface LanguageServerContext {
 		linterOptions?: Partial<stylelint.LinterOptions>,
 	): Promise<LintDiagnostics | undefined>;
 
+	/**
+	 * Gets the edit info from the given diagnostic.
+	 * @param document The document to lint.
+	 * @param diagnostics The diagnostic to get the edit info for.
+	 */
+	getEditInfo(
+		document: TextDocument,
+		diagnostics: LSP.Diagnostic,
+	): { label: string; edit: TextEdit } | undefined;
+
 	/**
 	 * Resolves the Stylelint package to be used for the given document.
 	 * @param document The document to resolve the package for.
diff --git a/src/utils/documents/get-edit-info.ts b/src/utils/documents/get-edit-info.ts
new file mode 100644
index 0000000..146bfcb
--- /dev/null
+++ b/src/utils/documents/get-edit-info.ts
@@ -0,0 +1,47 @@
+import type { TextDocument, TextEdit } from 'vscode-languageserver-textdocument';
+import type { LintDiagnostics } from '../stylelint/index';
+import type LSP from 'vscode-languageserver-protocol';
+
+/**
+ * Get the edit info for a diagnostic.
+ * @param document The document to get edit info for.
+ * @param diagnostic The diagnostic to get edit info for.
+ */
+export function getEditInfo(
+	document: TextDocument,
+	diagnostic: LSP.Diagnostic,
+	lintResult:
+		| (LintDiagnostics & {
+				version: number;
+		  })
+		| undefined,
+): { label: string; edit: TextEdit } | undefined {
+	if (!lintResult || document.version !== lintResult.version) {
+		return undefined;
+	}
+
+	const warning = lintResult.getWarning?.(diagnostic);
+
+	if (!warning) {
+		return undefined;
+	}
+
+	// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+	// @ts-ignore -- (TS2353) `fix` property is available since v16.15.
+	const edit = warning.fix;
+
+	if (!edit) {
+		return undefined;
+	}
+
+	return {
+		label: `Fix this ${warning.rule} problem`,
+		edit: {
+			newText: edit.text,
+			range: {
+				start: document.positionAt(edit.range[0]),
+				end: document.positionAt(edit.range[1]),
+			},
+		},
+	};
+}
diff --git a/src/utils/documents/index.ts b/src/utils/documents/index.ts
index f015bae..9eac9db 100644
--- a/src/utils/documents/index.ts
+++ b/src/utils/documents/index.ts
@@ -1,4 +1,5 @@
 export * from './create-text-edits';
+export * from './get-edit-info';
 export * from './get-disable-type';
 export * from './get-fixes';
 export * from './get-workspace-folder';
diff --git a/src/utils/stylelint/process-linter-result.ts b/src/utils/stylelint/process-linter-result.ts
index d18ccb0..1d9caef 100644
--- a/src/utils/stylelint/process-linter-result.ts
+++ b/src/utils/stylelint/process-linter-result.ts
@@ -1,7 +1,27 @@
+import * as crypto from 'crypto';
 import { warningToDiagnostic } from './warning-to-diagnostic';
 // eslint-disable-next-line n/no-unpublished-import
-import type { LinterResult } from 'stylelint';
+import type { LinterResult, Warning } from 'stylelint';
 import { type LintDiagnostics, type Stylelint, InvalidOptionError } from './types';
+import type LSP from 'vscode-languageserver-protocol';
+
+/**
+ * Returns a unique key for a diagnostic.
+ * @param diagnostic The diagnostic to get a key for.
+ */
+function getDiagnosticKey(diagnostic: LSP.Diagnostic): string {
+	const range = diagnostic.range;
+	let message = '';
+
+	if (diagnostic.message) {
+		const hash = crypto.createHash('sha256');
+
+		hash.update(diagnostic.message);
+		message = hash.digest('base64');
+	}
+
+	return `[${range.start.line},${range.start.character},${range.end.line},${range.end.character}]-${diagnostic.code}-${message}`;
+}
 
 /**
  * Processes the results of a Stylelint lint run.
@@ -54,8 +74,22 @@ export function processLinterResult(
 		);
 	}
 
-	const diagnostics = warnings.map((warning) => warningToDiagnostic(warning, ruleMetadata));
+	const diagnostics: LSP.Diagnostic[] = [];
+	const warningsMap = new Map<string, Warning>();
+
+	for (const warning of warnings) {
+		const diagnostic = warningToDiagnostic(warning, ruleMetadata);
+
+		diagnostics.push(diagnostic);
+		warningsMap.set(getDiagnosticKey(diagnostic), warning);
+	}
+
+	const getWarning = (diagnostic: LSP.Diagnostic): Warning | null => {
+		const key = getDiagnosticKey(diagnostic);
+
+		return warningsMap.get(key) ?? null;
+	};
 	const output = ('report' in linterResult && linterResult.report) || linterResult.output;
 
-	return output ? { output, diagnostics } : { diagnostics };
+	return output ? { output, diagnostics, getWarning } : { diagnostics, getWarning };
 }
diff --git a/src/utils/stylelint/stylelint-runner.ts b/src/utils/stylelint/stylelint-runner.ts
index 049cb65..e54c7c8 100644
--- a/src/utils/stylelint/stylelint-runner.ts
+++ b/src/utils/stylelint/stylelint-runner.ts
@@ -80,6 +80,9 @@ export class StylelintRunner {
 			...(await buildStylelintOptions(document.uri, workspaceFolder, linterOptions, runnerOptions)),
 			code: document.getText(),
 			formatter: () => '',
+			// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+			// @ts-ignore -- (TS2353) `computeEditInfo` option is available since v16.15.
+			computeEditInfo: true,
 		};
 
 		if (codeFilename) {
diff --git a/src/utils/stylelint/types.ts b/src/utils/stylelint/types.ts
index 2858bef..4107f53 100644
--- a/src/utils/stylelint/types.ts
+++ b/src/utils/stylelint/types.ts
@@ -19,6 +19,11 @@ export type LintDiagnostics = {
 	 * Raw output from Stylelint, if any.
 	 */
 	output?: string;
+
+	/**
+	 * Gets the original warning from the given diagnostic.
+	 */
+	getWarning?: (diagnostic: LSP.Diagnostic) => stylelint.Warning | null;
 };
 
 /**


Test patch:
diff --git a/src/server/__tests__/__snapshots__/server.ts.snap b/src/server/__tests__/__snapshots__/server.ts.snap
index ccd44d0..53c649e 100644
--- a/src/server/__tests__/__snapshots__/server.ts.snap
+++ b/src/server/__tests__/__snapshots__/server.ts.snap
@@ -188,6 +188,7 @@ exports[`StylelintLanguageServer should accept server modules 1`] = `
       "onWillSave": [MockFunction],
       "onWillSaveWaitUntil": [MockFunction],
     },
+    "getEditInfo": [Function],
     "getFixes": [Function],
     "getModule": [Function],
     "getOptions": [Function],
@@ -206,6 +207,33 @@ exports[`StylelintLanguageServer should accept server modules 1`] = `
 }
 `;
 
+exports[`StylelintLanguageServer should allow modules to get edit info for documents using context.getEditInfo 1`] = `
+[
+  [
+    {
+      "uri": "file:///test.css",
+    },
+    {
+      "code": "color-hex-length",
+      "message": "Expected "#000" to be "#000000" (color-hex-length)",
+      "range": {
+        "end": {
+          "character": 2,
+          "line": 1,
+        },
+        "start": {
+          "character": 1,
+          "line": 1,
+        },
+      },
+      "severity": 1,
+      "source": "stylelint",
+    },
+    undefined,
+  ],
+]
+`;
+
 exports[`StylelintLanguageServer should allow modules to get fixes for documents using context.getFixes 1`] = `
 [
   [
diff --git a/src/server/__tests__/server.ts b/src/server/__tests__/server.ts
index 9e1aa9e..c61cdf1 100644
--- a/src/server/__tests__/server.ts
+++ b/src/server/__tests__/server.ts
@@ -15,7 +15,7 @@ import { TextDocuments } from 'vscode-languageserver/node';
 import * as LSP from 'vscode-languageserver-protocol';
 import { displayError, NotificationManager, CommandManager } from '../../utils/lsp/index';
 import { LintDiagnostics, StylelintRunner } from '../../utils/stylelint/index';
-import { getFixes } from '../../utils/documents/index';
+import { getEditInfo, getFixes } from '../../utils/documents/index';
 import { StylelintResolver } from '../../utils/packages/index';
 import { StylelintLanguageServer } from '../server';
 import {
@@ -31,6 +31,7 @@ const mockNotificationManager = NotificationManager as jest.MockedClass<typeof N
 const mockCommandManager = CommandManager as jest.MockedClass<typeof CommandManager>;
 const mockRunner = StylelintRunner as jest.Mock<StylelintRunner>;
 const mockGetFixes = getFixes as jest.MockedFunction<typeof getFixes>;
+const mockGetEditInfo = getEditInfo as jest.MockedFunction<typeof getEditInfo>;
 const mockResolver = StylelintResolver as jest.Mock<StylelintResolver>;
 const mockTextDocuments = TextDocuments as jest.Mock<TextDocuments<TextDocument>>;
 
@@ -1239,4 +1240,42 @@ describe('StylelintLanguageServer', () => {
 			`"Cannot transition from Disposed"`,
 		);
 	});
+
+	test('should allow modules to get edit info for documents using context.getEditInfo', async () => {
+		mockRunner.mockImplementation(() => ({}) as unknown as StylelintRunner);
+		mockGetEditInfo.mockImplementation(
+			() => ({ label: 'test' }) as unknown as ReturnType<typeof getEditInfo>,
+		);
+
+		const document = { uri: 'file:///test.css' } as TextDocument;
+		const [TestModule, getContext] = getContextModule();
+		const server = new StylelintLanguageServer({
+			connection: mockConnection,
+			modules: [TestModule],
+		});
+
+		server.start();
+
+		const onInitializeHandler = mockConnection.onInitialize.mock.calls[0][0];
+
+		await onInitializeHandler(
+			{ capabilities: {} } as LSP.InitializeParams,
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		const editInfo = getContext()?.getEditInfo(document, {
+			range: {
+				start: { line: 1, character: 1 },
+				end: { line: 1, character: 2 },
+			},
+			message: `Expected "#000" to be "#000000" (color-hex-length)`,
+			source: 'stylelint',
+			severity: LSP.DiagnosticSeverity.Error,
+			code: 'color-hex-length',
+		});
+
+		expect(editInfo).toStrictEqual({ label: 'test' });
+		expect(mockGetEditInfo.mock.calls).toMatchSnapshot();
+	});
 });
diff --git a/src/server/modules/__tests__/__snapshots__/code-action.ts.snap b/src/server/modules/__tests__/__snapshots__/code-action.ts.snap
index fb50b32..5852aa0 100644
--- a/src/server/modules/__tests__/__snapshots__/code-action.ts.snap
+++ b/src/server/modules/__tests__/__snapshots__/code-action.ts.snap
@@ -18,6 +18,115 @@ exports[`CodeActionModule onInitialize should return results 1`] = `
 }
 `;
 
+exports[`CodeActionModule with action kind QuickFix, should create quick fix code action 1`] = `
+[
+  {
+    "edit": {
+      "documentChanges": [
+        {
+          "edits": [
+            {
+              "newText": "0000",
+              "range": {
+                "end": {
+                  "character": 11,
+                  "line": 0,
+                },
+                "start": {
+                  "character": 10,
+                  "line": 0,
+                },
+              },
+            },
+          ],
+          "textDocument": {
+            "uri": "foo",
+            "version": 1,
+          },
+        },
+      ],
+    },
+    "kind": "quickfix",
+    "title": "Fix",
+  },
+  {
+    "edit": {
+      "changeAnnotations": undefined,
+      "documentChanges": [
+        {
+          "edits": [
+            {
+              "newText": "/* stylelint-disable-next-line rule 1 */
+",
+              "range": {
+                "end": {
+                  "character": 0,
+                  "line": 0,
+                },
+                "start": {
+                  "character": 0,
+                  "line": 0,
+                },
+              },
+            },
+          ],
+          "textDocument": {
+            "uri": "foo",
+            "version": 1,
+          },
+        },
+      ],
+    },
+    "kind": "quickfix",
+    "title": "Disable rule 1 for this line",
+  },
+  {
+    "edit": {
+      "changeAnnotations": undefined,
+      "documentChanges": [
+        {
+          "edits": [
+            {
+              "newText": "/* stylelint-disable rule 1 */
+",
+              "range": {
+                "end": {
+                  "character": 0,
+                  "line": 0,
+                },
+                "start": {
+                  "character": 0,
+                  "line": 0,
+                },
+              },
+            },
+          ],
+          "textDocument": {
+            "uri": "foo",
+            "version": 1,
+          },
+        },
+      ],
+    },
+    "kind": "quickfix",
+    "title": "Disable rule 1 for the entire file",
+  },
+  {
+    "command": {
+      "arguments": [
+        {
+          "uri": "https://stylelint.io/user-guide/rules/rule",
+        },
+      ],
+      "command": "stylelint.openRuleDoc",
+      "title": "Open documentation for rule 1",
+    },
+    "kind": "quickfix",
+    "title": "Show documentation for rule 1",
+  },
+]
+`;
+
 exports[`CodeActionModule with action kind Source, should create fix-all code actions 1`] = `
 [
   {
diff --git a/src/server/modules/__tests__/code-action.ts b/src/server/modules/__tests__/code-action.ts
index aee6e35..8c19df5 100644
--- a/src/server/modules/__tests__/code-action.ts
+++ b/src/server/modules/__tests__/code-action.ts
@@ -535,4 +535,56 @@ describe('CodeActionModule', () => {
 		expect(disposables[0].value.dispose).toHaveBeenCalledTimes(1);
 		expect(disposables[1].value.dispose).toHaveBeenCalledTimes(1);
 	});
+
+	test('with action kind QuickFix, should create quick fix code action', async () => {
+		const document = TextDocument.create('foo', 'bar', 1, 'a{color:#000;}');
+		const diagnostic = {
+			message: 'Message for rule 1',
+			source: 'Stylelint',
+			range: LSP.Range.create(LSP.Position.create(0, 0), LSP.Position.create(0, 0)),
+			code: 'rule 1',
+			severity: LSP.DiagnosticSeverity.Error,
+			codeDescription: {
+				href: 'https://stylelint.io/user-guide/rules/rule',
+			},
+		};
+
+		mockContext.documents.get.mockReturnValue(document);
+		mockContext.__options.validate = ['bar'];
+		mockContext.getEditInfo.mockReturnValue({
+			label: 'Fix',
+			edit: {
+				newText: '0000',
+				range: {
+					start: { line: 0, character: 10 },
+					end: { line: 0, character: 11 },
+				},
+			},
+		});
+
+		const module = new CodeActionModule({
+			context: mockContext.__typed(),
+			logger: mockLogger,
+		});
+
+		module.onDidRegisterHandlers();
+
+		const handler = mockContext.connection.onCodeAction.mock.calls[0][0];
+
+		const result = await handler(
+			{
+				context: {
+					only: [LSP.CodeActionKind.QuickFix],
+					diagnostics: [diagnostic],
+				},
+				textDocument: { uri: 'foo' },
+				range: LSP.Range.create(LSP.Position.create(0, 0), LSP.Position.create(0, 0)),
+			},
+			{} as LSP.CancellationToken,
+			{} as WorkDoneProgressReporter,
+		);
+
+		expect(result).toMatchSnapshot();
+		expect(mockContext.getEditInfo).toHaveBeenCalledWith(document, diagnostic);
+	});
 });
diff --git a/src/utils/documents/__tests__/__snapshots__/get-edit-info.ts.snap b/src/utils/documents/__tests__/__snapshots__/get-edit-info.ts.snap
new file mode 100644
index 0000000..9c97a37
--- /dev/null
+++ b/src/utils/documents/__tests__/__snapshots__/get-edit-info.ts.snap
@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`getEditInfo should construct and return an EditInfo 1`] = `
+{
+  "edit": {
+    "newText": "0000",
+    "range": {
+      "end": {
+        "character": 13,
+        "line": 0,
+      },
+      "start": {
+        "character": 12,
+        "line": 0,
+      },
+    },
+  },
+  "label": "Fix this color-hex-length problem",
+}
+`;
diff --git a/src/utils/documents/__tests__/get-edit-info.ts b/src/utils/documents/__tests__/get-edit-info.ts
new file mode 100644
index 0000000..68d243c
--- /dev/null
+++ b/src/utils/documents/__tests__/get-edit-info.ts
@@ -0,0 +1,153 @@
+import { TextDocument } from 'vscode-languageserver-textdocument';
+import * as LSP from 'vscode-languageserver-protocol';
+
+import { getEditInfo } from '../get-edit-info';
+
+const DOCUMENT_VERSION = 1;
+const createDocument = (code: string) =>
+	TextDocument.create('file:///path/test.css', 'css', DOCUMENT_VERSION, code);
+
+describe('getEditInfo', () => {
+	it('should construct and return an EditInfo', async () => {
+		const document = createDocument('a {color: #000;}');
+
+		const warning = {
+			rule: 'color-hex-length',
+			text: `Expected "#000" to be "#000000" (color-hex-length)`,
+			line: 1,
+			column: 10,
+			endLine: 1,
+			endColumn: 14,
+			severity: 'error' as const,
+			fix: {
+				range: [12, 13] as [number, number],
+				text: '0000',
+			},
+		};
+		const diagnostic = {
+			range: {
+				start: document.positionAt(10),
+				end: document.positionAt(14),
+			},
+			message: `Expected "#000" to be "#000000" (color-hex-length)`,
+			source: 'stylelint',
+			severity: LSP.DiagnosticSeverity.Error,
+			code: 'color-hex-length',
+		};
+
+		const editInfo = getEditInfo(document, diagnostic, {
+			version: DOCUMENT_VERSION,
+			diagnostics: [diagnostic],
+			getWarning: () => warning,
+		});
+
+		expect(editInfo).toMatchSnapshot();
+	});
+
+	it('should return undefined if the document does not have a lint result', async () => {
+		const document = createDocument('a {color: #000;}');
+		const diagnostic = {
+			range: {
+				start: document.positionAt(10),
+				end: document.positionAt(14),
+			},
+			message: `Expected "#000" to be "#000000" (color-hex-length)`,
+			source: 'stylelint',
+			severity: LSP.DiagnosticSeverity.Error,
+			code: 'color-hex-length',
+		};
+
+		const editInfo = getEditInfo(document, diagnostic, undefined);
+
+		expect(editInfo).toBeUndefined();
+	});
+
+	it('should return undefined if the document version does not match', async () => {
+		const document = createDocument('a {color: #000;}');
+		const warning = {
+			rule: 'color-hex-length',
+			text: `Expected "#000" to be "#000000" (color-hex-length)`,
+			line: 1,
+			column: 10,
+			endLine: 1,
+			endColumn: 14,
+			severity: 'error' as const,
+			fix: {
+				range: [12, 13] as [number, number],
+				text: '0000',
+			},
+		};
+		const diagnostic = {
+			range: {
+				start: document.positionAt(10),
+				end: document.positionAt(14),
+			},
+			message: `Expected "#000" to be "#000000" (color-hex-length)`,
+			source: 'stylelint',
+			severity: LSP.DiagnosticSeverity.Error,
+			code: 'color-hex-length',
+		};
+
+		const editInfo = getEditInfo(document, diagnostic, {
+			version: 2,
+			diagnostics: [diagnostic],
+			getWarning: () => warning,
+		});
+
+		expect(editInfo).toBeUndefined();
+	});
+
+	it('should return undefined if the diagnostic has no warning', async () => {
+		const document = createDocument('a {color: #000;}');
+		const diagnostic = {
+			range: {
+				start: document.positionAt(10),
+				end: document.positionAt(14),
+			},
+			message: `Expected "#000" to be "#000000" (color-hex-length)`,
+			source: 'stylelint',
+			severity: LSP.DiagnosticSeverity.Error,
+			code: 'color-hex-length',
+		};
+
+		const editInfo = getEditInfo(document, diagnostic, {
+			version: DOCUMENT_VERSION,
+			diagnostics: [diagnostic],
+			getWarning: () => null,
+		});
+
+		expect(editInfo).toBeUndefined();
+	});
+
+	it('should return undefined if the warning has no fix', async () => {
+		const document = createDocument('a {color: #000;}');
+
+		const warning = {
+			rule: 'color-hex-length',
+			text: `Expected "#000" to be "#000000" (color-hex-length)`,
+			line: 1,
+			column: 10,
+			endLine: 1,
+			endColumn: 14,
+			severity: 'error' as const,
+		};
+		const diagnostic = {
+			range: {
+				start: document.positionAt(10),
+				end: document.positionAt(14),
+			},
+			message: `Expected "#000" to be "#000000" (color-hex-length)`,
+			source: 'stylelint',
+			severity: LSP.DiagnosticSeverity.Error,
+			code: 'color-hex-length',
+		};
+
+		const editInfo = getEditInfo(document, diagnostic, {
+			version: DOCUMENT_VERSION,
+			diagnostics: [diagnostic],
+			getWarning: () => warning,
+		});
+
+		expect(editInfo).toBeUndefined();
+	});
+});
diff --git a/src/utils/stylelint/__tests__/__snapshots__/process-linter-result.ts.snap b/src/utils/stylelint/__tests__/__snapshots__/process-linter-result.ts.snap
index c30d479..4aa8737 100644
--- a/src/utils/stylelint/__tests__/__snapshots__/process-linter-result.ts.snap
+++ b/src/utils/stylelint/__tests__/__snapshots__/process-linter-result.ts.snap
@@ -52,6 +52,7 @@ exports[`processLinterResult should return diagnostics for each warning 1`] = `
       "source": "Stylelint",
     },
   ],
+  "getWarning": [Function],
 }
 `;
 
@@ -75,6 +76,7 @@ exports[`processLinterResult should return output if given 1`] = `
       "source": "Stylelint",
     },
   ],
+  "getWarning": [Function],
   "output": "Output",
 }
 `;
diff --git a/src/utils/stylelint/__tests__/__snapshots__/stylelint-runner.ts.snap b/src/utils/stylelint/__tests__/__snapshots__/stylelint-runner.ts.snap
index 4b55de4..009531e 100644
--- a/src/utils/stylelint/__tests__/__snapshots__/stylelint-runner.ts.snap
+++ b/src/utils/stylelint/__tests__/__snapshots__/stylelint-runner.ts.snap
@@ -4,6 +4,7 @@ exports[`StylelintRunner should call stylelint.lint with the document path and g
 {
   "code": "a {}",
   "codeFilename": "/path/to/file.scss",
+  "computeEditInfo": true,
   "config": {
     "customSyntax": "postcss-scss",
   },
@@ -20,6 +21,8 @@ exports[`StylelintRunner should call stylelint.lint with the document path and g
 }
 `;
 
+exports[`StylelintRunner should not get the warning with getWarning and a non-existent diagnostic 1`] = `null`;
+
 exports[`StylelintRunner should return processed lint results from Stylelint with configured rules 1`] = `
 {
   "diagnostics": [
@@ -43,6 +46,7 @@ exports[`StylelintRunner should return processed lint results from Stylelint wit
       "source": "Stylelint",
     },
   ],
+  "getWarning": [Function],
 }
 `;
 
@@ -66,6 +70,7 @@ exports[`StylelintRunner should return processed lint results from Stylelint wit
       "source": "Stylelint",
     },
   ],
+  "getWarning": [Function],
 }
 `;
 
diff --git a/src/utils/stylelint/__tests__/stylelint-runner.ts b/src/utils/stylelint/__tests__/stylelint-runner.ts
index 4f76dd9..ff7afe3 100644
--- a/src/utils/stylelint/__tests__/stylelint-runner.ts
+++ b/src/utils/stylelint/__tests__/stylelint-runner.ts
@@ -14,9 +14,12 @@ import stylelint from 'stylelint';
 import type winston from 'winston';
 import type { Connection } from 'vscode-languageserver';
 import type { TextDocument } from 'vscode-languageserver-textdocument';
+import * as LSP from 'vscode-languageserver-protocol';
 import { StylelintResolver, ResolverOptions } from '../../packages/index';
 import { getWorkspaceFolder } from '../../documents/index';
 import { StylelintRunner } from '../stylelint-runner';
+import { version as stylelintVersion } from 'stylelint/package.json';
+import semver from 'semver';
 
 const mockedOS = os as tests.mocks.OSModule;
 const mockedPath = path as tests.mocks.PathModule;
@@ -278,4 +281,60 @@ describe('StylelintRunner', () => {
 			expect.any(Object),
 		);
 	});
+
+	if (semver.satisfies(stylelintVersion, '>=16.15')) {
+		it('should be possible to get the original warning via getWarning', async () => {
+			expect.assertions(1);
+
+			mockedPath.__mockPlatform();
+
+			mockedResolver.mockImplementation(createMockResolver(undefined, async () => ({ stylelint })));
+
+			const results = await new StylelintRunner(mockConnection).lintDocument(
+				createMockDocument('a {color:#fff}', '/path/to/file.css'),
+				{ config: { rules: { 'color-hex-length': 'long' } } },
+			);
+
+			expect(results.getWarning?.(results.diagnostics[0])).toEqual({
+				column: 10,
+				endColumn: 14,
+				endLine: 1,
+				fix: {
+					range: [12, 13],
+					text: 'ffff',
+				},
+				line: 1,
+				rule: 'color-hex-length',
+				severity: 'error',
+				text: `Expected "#fff" to be "#ffffff" (color-hex-length)`,
+				url: undefined,
+			});
+		});
+	}
+
+	it('should not get the warning with getWarning and a non-existent diagnostic', async () => {
+		expect.assertions(1);
+
+		mockedPath.__mockPlatform();
+
+		mockedResolver.mockImplementation(createMockResolver(undefined, async () => ({ stylelint })));
+
+		const results = await new StylelintRunner(mockConnection).lintDocument(
+			createMockDocument('a {color:#fff}', '/path/to/file.css'),
+			{ config: { rules: { 'color-hex-length': 'long' } } },
+		);
+
+		expect(
+			results.getWarning?.({
+				message: 'Message for rule 1',
+				source: 'Stylelint',
+				range: LSP.Range.create(LSP.Position.create(0, 0), LSP.Position.create(0, 0)),
+				code: 'rule 1',
+				severity: LSP.DiagnosticSeverity.Error,
+				codeDescription: {
+					href: 'https://stylelint.io/user-guide/rules/rule',
+				},
+			}),
+		).toMatchSnapshot();
+	});
 });
diff --git a/test/e2e/__tests__/code-actions.ts b/test/e2e/__tests__/code-actions.ts
index b29d08d..1a22ade 100644
--- a/test/e2e/__tests__/code-actions.ts
+++ b/test/e2e/__tests__/code-actions.ts
@@ -1,5 +1,6 @@
 import * as assert from 'node:assert/strict';
 import { EOL } from 'node:os';
+import * as semver from 'semver';
 
 import { workspace, Selection, Position } from 'vscode';
 
@@ -15,6 +16,7 @@ import {
 } from '../helpers';
 
 const cssPath = 'code-actions/test.css';
+const cssForEditInfoPath = 'code-actions/edit-info.css';
 const jsPath = 'code-actions/test.js';
 
 describe('Code actions', () => {
@@ -30,25 +32,67 @@ describe('Code actions', () => {
 
 		await waitForDiagnostics(editor);
 
-		const actions = await getCodeActions(editor, new Selection(1, 11, 1, 11));
+		const actions = await getCodeActions(editor, new Selection(3, 11, 3, 11));
 
 		assert.equal(actions.length, 3);
 		assertTextEdits(actions[0].edit?.get(editor.document.uri), [
 			{
-				newText: `  /* stylelint-disable-next-line value-keyword-case */${EOL}`,
-				range: [1, 0, 1, 0],
+				newText: `  /* stylelint-disable-next-line color-no-invalid-hex */${EOL}`,
+				range: [3, 0, 3, 0],
 			},
 		]);
 		assertTextEdits(actions[1].edit?.get(editor.document.uri), [
 			{
-				newText: `/* stylelint-disable value-keyword-case */${EOL}`,
+				newText: `/* stylelint-disable color-no-invalid-hex */${EOL}`,
 				range: [0, 0, 0, 0],
 			},
 		]);
 		assertCommand(actions[2].command, {
-			title: 'Open documentation for value-keyword-case',
+			title: 'Open documentation for color-no-invalid-hex',
 			command: 'stylelint.openRuleDoc',
-			arguments: [{ uri: 'https://stylelint.io/user-guide/rules/value-keyword-case' }],
+			arguments: [{ uri: 'https://stylelint.io/user-guide/rules/color-no-invalid-hex' }],
+		});
+	});
+
+	// eslint-disable-next-line @typescript-eslint/no-require-imports
+	const hasEditInfo = semver.satisfies(require('stylelint/package.json').version, '>=16.15');
+
+	// Rules with EditInfo are only available in Stylelint 16.15 and later.
+	const ifItHaveProblemEditsIt = hasEditInfo ? it : it.skip;
+
+	ifItHaveProblemEditsIt('should provide code actions for problem edit info', async () => {
+		const editor = await openDocument(cssForEditInfoPath);
+
+		await waitForDiagnostics(editor);
+
+		const actions = await getCodeActions(editor, new Selection(1, 11, 1, 11));
+
+		assert.equal(actions.length, 4);
+		const editAction = actions[0];
+
+		assert.equal(editAction.title, 'Fix this color-hex-length problem');
+		assertTextEdits(editAction.edit?.get(editor.document.uri), [
+			{
+				newText: '0000',
+				range: [1, 12, 1, 13],
+			},
+		]);
+		assertTextEdits(actions[1].edit?.get(editor.document.uri), [
+			{
+				newText: `  /* stylelint-disable-next-line color-hex-length */${EOL}`,
+				range: [1, 0, 1, 0],
+			},
+		]);
+		assertTextEdits(actions[2].edit?.get(editor.document.uri), [
+			{
+				newText: `/* stylelint-disable color-hex-length */${EOL}`,
+				range: [0, 0, 0, 0],
+			},
+		]);
+		assertCommand(actions[3].command, {
+			title: 'Open documentation for color-hex-length',
+			command: 'stylelint.openRuleDoc',
+			arguments: [{ uri: 'https://stylelint.io/user-guide/rules/color-hex-length' }],
 		});
 	});
 
@@ -193,4 +237,28 @@ const css = css\`
 			);
 		});
 	});
+
+	ifItHaveProblemEditsIt(
+		'should be fixed to correct position with a quick fix provided by EditInfo',
+		async () => {
+			const editor = await openDocument(cssForEditInfoPath);
+
+			await waitForDiagnostics(editor);
+
+			const actions = await getCodeActions(editor, new Selection(1, 11, 1, 11));
+			const quickFixAction = actions.find((action) => action.title.match(/^Fix this .+ problem$/));
+
+			assert.ok(quickFixAction?.edit);
+
+			await workspace.applyEdit(quickFixAction.edit);
+
+			assert.equal(
+				editor.document.getText(),
+				`a {
+  color: #000000;
+}
+`,
+			);
+		},
+	);
 });
diff --git a/test/e2e/__tests__/format.ts b/test/e2e/__tests__/format.ts
index e284cae..cde6d41 100644
--- a/test/e2e/__tests__/format.ts
+++ b/test/e2e/__tests__/format.ts
@@ -1,4 +1,5 @@
 import * as assert from 'node:assert/strict';
+import * as semver from 'semver';
 
 import { commands } from 'vscode';
 
@@ -12,7 +13,7 @@ describe('Document formatting', () => {
 	});
 
 	// eslint-disable-next-line @typescript-eslint/no-require-imports
-	const isStylelint16 = require('stylelint/package.json').version.startsWith('16.');
+	const isStylelint16 = semver.satisfies(require('stylelint/package.json').version, '>=16');
 
 	// Only Stylelint before 16 had formatting related rules
 	const pre16It = isStylelint16 ? it.skip : it;
diff --git a/test/e2e/workspace/code-actions/.vscode/settings.json b/test/e2e/workspace/code-actions/.vscode/settings.json
index caa129a..4a436ee 100644
--- a/test/e2e/workspace/code-actions/.vscode/settings.json
+++ b/test/e2e/workspace/code-actions/.vscode/settings.json
@@ -1,7 +1,7 @@
 {
 	"[css]": {
 		"editor.codeActionsOnSave": {
-			"source.fixAll.stylelint": true
+			"source.fixAll.stylelint": "explicit"
 		}
 	},
 	"stylelint.reportNeedlessDisables": true,
diff --git a/test/e2e/workspace/code-actions/edit-info.css b/test/e2e/workspace/code-actions/edit-info.css
new file mode 100644
index 0000000..6417f9e
--- /dev/null
+++ b/test/e2e/workspace/code-actions/edit-info.css
@@ -0,0 +1,3 @@
+a {
+  color: #000;
+}
diff --git a/test/e2e/workspace/code-actions/stylelint.config.js b/test/e2e/workspace/code-actions/stylelint.config.js
index ac6a267..85be803 100644
--- a/test/e2e/workspace/code-actions/stylelint.config.js
+++ b/test/e2e/workspace/code-actions/stylelint.config.js
@@ -5,6 +5,7 @@ const config = {
 	rules: {
 		'color-no-invalid-hex': true,
 		'value-keyword-case': 'lower',
+		'color-hex-length': 'long',
 	},
 	overrides: [
 		{
diff --git a/test/integration/stylelint-vscode/__snapshots__/test.ts.snap b/test/integration/stylelint-vscode/__snapshots__/test.ts.snap
index e5ce749..b44a0bc 100644
--- a/test/integration/stylelint-vscode/__snapshots__/test.ts.snap
+++ b/test/integration/stylelint-vscode/__snapshots__/test.ts.snap
@@ -238,6 +238,7 @@ exports[`StylelintRunner with auto-fix auto-fix should work if there are errors
       "source": "Stylelint",
     },
   ],
+  "getWarning": [Function],
   "output": "
 unknown {
     color: #fff;
@@ -266,6 +267,7 @@ color:#fff;
 exports[`StylelintRunner with customSyntax auto-fix should work properly if customSyntax is defined 1`] = `
 {
   "diagnostics": [],
+  "getWarning": [Function],
   "output": "a
   color:#fff",
 }
@@ -294,6 +296,7 @@ exports[`StylelintRunner with customSyntax should work properly if customSyntax
       "source": "Stylelint",
     },
   ],
+  "getWarning": [Function],
 }
 `;
 
@@ -349,6 +352,7 @@ exports[`StylelintRunner with reportDescriptionlessDisables should work properly
       "source": "Stylelint",
     },
   ],
+  "getWarning": [Function],
 }
 `;
 
@@ -404,6 +408,7 @@ exports[`StylelintRunner with reportInvalidScopeDisables should work properly if
       "source": "Stylelint",
     },
   ],
+  "getWarning": [Function],
 }
 `;
 
@@ -494,6 +499,7 @@ exports[`StylelintRunner with reportNeedlessDisables should work properly if rep
       "source": "Stylelint",
     },
   ],
+  "getWarning": [Function],
 }
 `;
 
@@ -517,6 +523,7 @@ exports[`StylelintRunner with stylelintPath should work properly if custom path
       "source": "Stylelint",
     },
   ],
+  "getWarning": [Function],
 }
 `;
 
@@ -543,5 +550,6 @@ exports[`StylelintRunner with stylelintPath should work properly if stylelintPat
       "source": "Stylelint",
     },
   ],
+  "getWarning": [Function],
 }
 `;
diff --git a/test/unit/server-mocks.ts b/test/unit/server-mocks.ts
index d35f179..c5400c1 100644
--- a/test/unit/server-mocks.ts
+++ b/test/unit/server-mocks.ts
@@ -569,6 +569,7 @@ export function getContext(): MockLanguageServerContext {
 		displayError: jest.fn(),
 		getOptions: jest.fn(async () => context.__options) as JestFn,
 		getFixes: jest.fn(),
+		getEditInfo: jest.fn(),
 		getModule: jest.fn(),
 		lintDocument: jest.fn(),
 		resolveStylelint: jest.fn(),