Repository: stylelint/vscode-stylelint (ts)

Issue:
[Feature]: Surface "Error: No rules found within configuration. Have you provided a "rules" property?"
### What problem does this feature solve?

Users are unaware if no rules are turned on in their configuration because the extension doesn't surface this error.

### How does this feature solve the problem?

Given a config of:

```
const config = {};
export default config;
```

The Stylelint CLI shows the following error:

```console
jeddy3@mac stylelint-test % npx stylelint "**/*.css"
Error: No rules found within configuration. Have you provided a "rules" property?
```

The extension should surface this error. It'll also help users who accidentally set [`stylelint.config`](https://marketplace.visualstudio.com/items?itemName=stylelint.vscode-stylelint#stylelint.config) to `{}`, which has cropped up a few times.

https://github.com/stylelint/stylelint/issues/7429


### What are some alternatives to this feature?

_No response_

### Code of Conduct

- [X] I agree to follow vscode-stylelint's Code of Conduct

Pull request description:
feat: diagnostic for rule-less Stylelint config
Closes #487

Adds an error diagnostic corresponding to the Stylelint error displayed when the configuration has no rules defined.

Patch:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 7a768fe..9bc25f9 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -10,7 +10,14 @@ import tsEslintParser from '@typescript-eslint/parser'; // eslint-disable-line n
 const nodeVersion = fs.readFileSync(new URL('.nvmrc', import.meta.url), 'utf8');
 
 export default defineConfig([
-	globalIgnores(['.vscode-test', 'build', 'coverage', 'dist', 'test/e2e/workspace']),
+	globalIgnores([
+		'.vscode-test',
+		'build',
+		'coverage',
+		'dist',
+		'test/integration/coverage',
+		'test/e2e/workspace',
+	]),
 
 	...stylelint,
 
diff --git a/package.json b/package.json
index c89ecc7..20c47f0 100644
--- a/package.json
+++ b/package.json
@@ -296,7 +296,7 @@
     "lint:types": "tsc -b",
     "lint:unit-tests": "ts-node --transpile-only -P tsconfig.scripts.json scripts/enforce-unit-tests-per-file.ts",
     "test": "npm run build-bundle && npm run jest -- ",
-    "test:e2e": "npm run build-bundle && vscode-test && prettier -w --parser jsonc test/e2e/workspace/workspace.code-workspace",
+    "test:e2e": "node scripts/run-e2e.js",
     "test:integration": "npm run jest -- --projects test/integration",
     "test:unit": "npm run jest -- --projects test/unit",
     "test:node-versions": "node scripts/test-node-versions.js",
diff --git a/src/utils/stylelint/stylelint-runner.ts b/src/utils/stylelint/stylelint-runner.ts
index 400cc75..59d945a 100644
--- a/src/utils/stylelint/stylelint-runner.ts
+++ b/src/utils/stylelint/stylelint-runner.ts
@@ -1,5 +1,6 @@
 import os from 'os';
 import { URI } from 'vscode-uri';
+import { DiagnosticSeverity, Position, Range } from 'vscode-languageserver-types';
 import type { Connection } from 'vscode-languageserver';
 import type { TextDocument } from 'vscode-languageserver-textdocument';
 import type stylelint from 'stylelint';
@@ -101,17 +102,36 @@ export class StylelintRunner {
 				runnerOptions.rules?.customizations,
 			);
 		} catch (err) {
-			if (
-				err instanceof Error &&
-				(err.message.startsWith('No configuration provided for') ||
-					err.message.includes('No rules found within configuration'))
-			) {
-				// Check only CSS syntax errors without applying any Stylelint rules
-				return processLinterResult(
+			if (!(err instanceof Error)) {
+				throw err;
+			}
+
+			const lintSyntax = async (): Promise<LintDiagnostics> =>
+				processLinterResult(
 					result.stylelint,
 					await result.stylelint.lint({ ...options, config: { rules: {} } }),
 					runnerOptions.rules?.customizations,
 				);
+
+			if (err.message.startsWith('No configuration provided for')) {
+				// Check only CSS syntax errors without applying any Stylelint rules.
+				return await lintSyntax();
+			}
+
+			if (err.message.includes('No rules found within configuration')) {
+				// Always run syntax-only linting to catch CSS syntax errors.
+				const combinedResult = await lintSyntax();
+
+				// Add the configuration error diagnostic to the syntax results.
+				combinedResult.diagnostics.push({
+					range: Range.create(Position.create(0, 0), Position.create(0, 0)),
+					message: err.message,
+					severity: DiagnosticSeverity.Error,
+					source: 'Stylelint',
+					code: 'no-rules-configured',
+				});
+
+				return combinedResult;
 			}
 
 			throw err;


Test patch:
diff --git a/scripts/run-e2e.js b/scripts/run-e2e.js
new file mode 100644
index 0000000..ca195e9
--- /dev/null
+++ b/scripts/run-e2e.js
@@ -0,0 +1,59 @@
+'use strict';
+
+const { execSync, spawn } = require('child_process');
+const process = require('process');
+
+const packageJson = require('../package.json');
+
+/**
+ * Run end-to-end tests for the VS Code extension.
+ */
+async function runE2ETest() {
+	try {
+		// Build bundle.
+		console.log('Building bundle...');
+		execSync('npm run build-bundle', { stdio: 'inherit' });
+
+		// Run vscode-test with all passed arguments.
+		console.log();
+		console.log(`> ${packageJson.name}@${packageJson.version} vscode-test`);
+		console.log(`> vscode-test ${process.argv.slice(2).join(' ')}`);
+		console.log();
+		const args = process.argv.slice(2);
+		const vscodeTest = spawn('npm', ['exec', 'vscode-test', '--', ...args], {
+			shell: process.platform === 'win32',
+			stdio: 'inherit',
+		});
+
+		const exitCode = await new Promise((resolve) => {
+			vscodeTest.on('close', resolve);
+		});
+
+		return exitCode;
+	} catch (error) {
+		console.error('Error during build or test:', error instanceof Error ? error.message : error);
+
+		return 1;
+	} finally {
+		// Always format the workspace file.
+		try {
+			console.log('Formatting workspace file...');
+			execSync(
+				'npm exec prettier -- -w --parser jsonc test/e2e/workspace/workspace.code-workspace',
+				{
+					stdio: 'inherit',
+				},
+			);
+		} catch (formatError) {
+			console.error(
+				'Error formatting workspace file:',
+				formatError instanceof Error ? formatError.message : formatError,
+			);
+		}
+	}
+}
+
+runE2ETest().then((exitCode) => {
+	// eslint-disable-next-line n/no-process-exit
+	process.exit(exitCode);
+});
diff --git a/src/utils/stylelint/__tests__/stylelint-runner.ts b/src/utils/stylelint/__tests__/stylelint-runner.ts
index ff7afe3..bcfd5b8 100644
--- a/src/utils/stylelint/__tests__/stylelint-runner.ts
+++ b/src/utils/stylelint/__tests__/stylelint-runner.ts
@@ -337,4 +337,116 @@ describe('StylelintRunner', () => {
 			}),
 		).toMatchSnapshot();
 	});
+
+	test('should throw non-Error objects as-is', async () => {
+		expect.assertions(1);
+
+		mockedResolver.mockImplementation(
+			createMockResolver(async () => {
+				// Throw a non-error object to test error handling.
+				// eslint-disable-next-line @typescript-eslint/only-throw-error
+				throw 'This is not an Error object';
+			}),
+		);
+
+		await expect(
+			new StylelintRunner(mockConnection).lintDocument(createMockDocument('a {}')),
+		).rejects.toBe('This is not an Error object');
+	});
+
+	test('should handle "No rules found within configuration" error by adding configuration diagnostic', async () => {
+		expect.assertions(1);
+
+		mockedResolver.mockImplementation(
+			createMockResolver(async (options) => {
+				if (options.config?.rules && Object.keys(options.config.rules).length === 0) {
+					// Return empty results for syntax-only linting.
+					return { results: [] };
+				}
+
+				// Throw the "no rules found" error for normal linting.
+				throw new Error(
+					'No rules found within configuration. Have you provided a "rules" property?',
+				);
+			}),
+		);
+
+		const results = await new StylelintRunner(mockConnection).lintDocument(
+			createMockDocument('a {}'),
+			{ config: {} },
+		);
+
+		expect(results).toEqual({
+			diagnostics: [
+				{
+					range: {
+						start: { line: 0, character: 0 },
+						end: { line: 0, character: 0 },
+					},
+					message: 'No rules found within configuration. Have you provided a "rules" property?',
+					severity: 1,
+					source: 'Stylelint',
+					code: 'no-rules-configured',
+				},
+			],
+		});
+	});
+
+	test('should combine syntax errors with configuration error when no rules are defined', async () => {
+		expect.assertions(2);
+
+		mockedResolver.mockImplementation(
+			createMockResolver(async (options) => {
+				if (options.config?.rules && Object.keys(options.config.rules).length === 0) {
+					// Return syntax error for syntax-only linting.
+					return {
+						results: [
+							{
+								source: 'a {',
+								warnings: [
+									{
+										line: 1,
+										column: 3,
+										endLine: 1,
+										endColumn: 3,
+										rule: 'CssSyntaxError',
+										severity: 'error',
+										text: 'Unclosed block (CssSyntaxError)',
+									},
+								],
+								deprecations: [],
+								invalidOptionWarnings: [],
+								parseErrors: [],
+							},
+						],
+					};
+				}
+
+				// Throw the "no rules found" error for normal linting.
+				throw new Error(
+					'No rules found within configuration. Have you provided a "rules" property?',
+				);
+			}),
+		);
+
+		const results = await new StylelintRunner(mockConnection).lintDocument(
+			createMockDocument('a {'),
+			{ config: {} },
+		);
+
+		// Should have both syntax error and configuration error.
+		expect(results.diagnostics).toHaveLength(2);
+		expect(results.diagnostics).toEqual(
+			expect.arrayContaining([
+				expect.objectContaining({
+					code: 'no-rules-configured',
+					message: 'No rules found within configuration. Have you provided a "rules" property?',
+				}),
+				expect.objectContaining({
+					code: 'CssSyntaxError',
+					message: 'Unclosed block (CssSyntaxError)',
+				}),
+			]),
+		);
+	});
 });
diff --git a/test/e2e/__tests__/no-rules-configured.ts b/test/e2e/__tests__/no-rules-configured.ts
new file mode 100644
index 0000000..e56aef4
--- /dev/null
+++ b/test/e2e/__tests__/no-rules-configured.ts
@@ -0,0 +1,80 @@
+import * as assert from 'node:assert/strict';
+
+import {
+	openDocument,
+	waitForDiagnostics,
+	assertDiagnostics,
+	executeAutofix,
+	closeAllEditors,
+} from '../helpers';
+
+describe('No rules configured', () => {
+	afterEach(async () => {
+		await closeAllEditors();
+	});
+
+	it('should show configuration error when no rules are defined', async () => {
+		const { document } = await openDocument('no-rules-configured/test.css');
+		const diagnostics = await waitForDiagnostics(document);
+
+		assertDiagnostics(diagnostics, [
+			{
+				code: 'no-rules-configured',
+				message: 'No rules found within configuration. Have you provided a "rules" property?',
+				range: [0, 0, 0, 0],
+				severity: 'error',
+			},
+		]);
+	});
+
+	it('should show both syntax errors and configuration error when no rules are defined', async () => {
+		const { document } = await openDocument('no-rules-configured/test-syntax-error.css');
+		const diagnostics = await waitForDiagnostics(document);
+
+		assertDiagnostics(diagnostics, [
+			{
+				code: 'CssSyntaxError',
+				message: 'Unclosed block (CssSyntaxError)',
+				range: [1, 0, 1, 1],
+				severity: 'error',
+			},
+			{
+				code: 'no-rules-configured',
+				message: 'No rules found within configuration. Have you provided a "rules" property?',
+				range: [0, 0, 0, 0],
+				severity: 'error',
+			},
+		]);
+	});
+
+	it('should auto-fix syntax errors when no rules are defined', async () => {
+		const { document } = await openDocument('no-rules-configured/test-syntax-error.css');
+		const diagnostics = await waitForDiagnostics(document);
+
+		assertDiagnostics(diagnostics, [
+			{
+				code: 'CssSyntaxError',
+				message: 'Unclosed block (CssSyntaxError)',
+				range: [1, 0, 1, 1],
+				severity: 'error',
+			},
+			{
+				code: 'no-rules-configured',
+				message: 'No rules found within configuration. Have you provided a "rules" property?',
+				range: [0, 0, 0, 0],
+				severity: 'error',
+			},
+		]);
+
+		await executeAutofix();
+
+		const fixedContent = document.getText();
+
+		assert.ok(fixedContent.includes('}'), 'Auto-fix should add the missing closing brace');
+
+		const postFixDiagnostics = await waitForDiagnostics(document);
+		const hasConfigError = postFixDiagnostics.some((d) => d.code === 'no-rules-configured');
+
+		assert.ok(hasConfigError, 'Configuration error should still be present after autofix');
+	});
+});
diff --git a/test/e2e/workspace/no-rules-configured/.vscode/settings.json b/test/e2e/workspace/no-rules-configured/.vscode/settings.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/test/e2e/workspace/no-rules-configured/.vscode/settings.json
@@ -0,0 +1,1 @@
+{}
diff --git a/test/e2e/workspace/no-rules-configured/stylelint.config.js b/test/e2e/workspace/no-rules-configured/stylelint.config.js
new file mode 100644
index 0000000..5403ec9
--- /dev/null
+++ b/test/e2e/workspace/no-rules-configured/stylelint.config.js
@@ -0,0 +1,5 @@
+'use strict';
+
+module.exports = {
+	// No rules defined - this should trigger the diagnostic.
+};
diff --git a/test/e2e/workspace/no-rules-configured/test-syntax-error.css b/test/e2e/workspace/no-rules-configured/test-syntax-error.css
new file mode 100644
index 0000000..7a8a415
--- /dev/null
+++ b/test/e2e/workspace/no-rules-configured/test-syntax-error.css
@@ -0,0 +1,3 @@
+/* CSS with syntax error to test both syntax errors and configuration error. */
+a {
+  color: red
diff --git a/test/e2e/workspace/no-rules-configured/test.css b/test/e2e/workspace/no-rules-configured/test.css
new file mode 100644
index 0000000..fff4d99
--- /dev/null
+++ b/test/e2e/workspace/no-rules-configured/test.css
@@ -0,0 +1,4 @@
+/* Valid CSS that should only show the configuration error. */
+a {
+  color: red;
+}
diff --git a/test/e2e/workspace/workspace.code-workspace b/test/e2e/workspace/workspace.code-workspace
index 1ea40de..707a5bd 100644
--- a/test/e2e/workspace/workspace.code-workspace
+++ b/test/e2e/workspace/workspace.code-workspace
@@ -6,6 +6,7 @@
 		{ "path": "defaults" },
 		{ "path": "descriptionless-disables" },
 		{ "path": "ignore-disables" },
+		{ "path": "no-rules-configured" },
 		{ "path": "report-disables" },
 		{ "path": "severity-override" },
 		{ "path": "stylelint-path" },
diff --git a/test/integration/stylelint-vscode/test.ts b/test/integration/stylelint-vscode/test.ts
index 7c4e708..bc314ff 100644
--- a/test/integration/stylelint-vscode/test.ts
+++ b/test/integration/stylelint-vscode/test.ts
@@ -221,14 +221,119 @@ a { color: #000 }
 		expect(result.diagnostics).toMatchSnapshot();
 	});
 
-	test('should be resolved even if no rules are defined', async () => {
+	test('should surface configuration error when no rules are defined and fix is not enabled', async () => {
 		expect.assertions(1);
 		const runner = new StylelintRunner();
 		const result = await runner.lintDocument(createDocument('no-rules.css', 'css', 'a{}'), {
 			config: {},
 		});
 
-		expect(result.diagnostics).toEqual([]);
+		// Should get the configuration error diagnostic.
+		expect(result.diagnostics).toEqual([
+			{
+				range: {
+					start: { line: 0, character: 0 },
+					end: { line: 0, character: 0 },
+				},
+				message: 'No rules found within configuration. Have you provided a "rules" property?',
+				severity: 1,
+				source: 'Stylelint',
+				code: 'no-rules-configured',
+			},
+		]);
+	});
+
+	test('should surface both syntax errors and configuration error when no rules are defined', async () => {
+		expect.assertions(3);
+		const runner = new StylelintRunner();
+		// Invalid CSS syntax, missing closing brace.
+		const result = await runner.lintDocument(createDocument('no-rules.css', 'css', 'a{color:red'), {
+			config: {},
+		});
+
+		// Should get both syntax errors and the configuration error.
+		expect(result.diagnostics.length).toBeGreaterThan(1);
+
+		// Should have the configuration error.
+		const configError = result.diagnostics.find((d) => d.code === 'no-rules-configured');
+
+		expect(configError).toEqual({
+			range: {
+				start: { line: 0, character: 0 },
+				end: { line: 0, character: 0 },
+			},
+			message: 'No rules found within configuration. Have you provided a "rules" property?',
+			severity: 1,
+			source: 'Stylelint',
+			code: 'no-rules-configured',
+		});
+
+		// Should also have syntax errors.
+		const syntaxErrors = result.diagnostics.filter((d) => d.code !== 'no-rules-configured');
+
+		expect(syntaxErrors.length).toBeGreaterThan(0);
+	});
+
+	test('should surface configuration error when no rules are defined when auto-fixing', async () => {
+		expect.assertions(2);
+		const runner = new StylelintRunner();
+		// Invalid CSS syntax, missing closing brace.
+		const result = await runner.lintDocument(createDocument('no-rules.css', 'css', 'a{color:red'), {
+			config: {},
+			fix: true,
+		});
+
+		// Should have the configuration error.
+		const configError = result.diagnostics.find((d) => d.code === 'no-rules-configured');
+
+		expect(configError).toEqual({
+			range: {
+				start: { line: 0, character: 0 },
+				end: { line: 0, character: 0 },
+			},
+			message: 'No rules found within configuration. Have you provided a "rules" property?',
+			severity: 1,
+			source: 'Stylelint',
+			code: 'no-rules-configured',
+		});
+
+		// When auto-fixing is enabled, syntax errors should be fixed and not reported as diagnostics,
+		// so we should only have the configuration error left.
+		expect(result.diagnostics).toHaveLength(1);
+	});
+
+	test('should work normally when rules are defined', async () => {
+		expect.assertions(1);
+		const runner = new StylelintRunner();
+		const result = await runner.lintDocument(createDocument('with-rules.css', 'css', 'a{}'), {
+			config: {
+				rules: {
+					'block-no-empty': true,
+				},
+			},
+		});
+
+		expect(result.diagnostics).toEqual([
+			{
+				code: 'block-no-empty',
+				codeDescription: {
+					href: 'https://stylelint.io/user-guide/rules/block-no-empty',
+				},
+				message: 'Unexpected empty block (block-no-empty)',
+				range: {
+					end: {
+						character: 3,
+						line: 0,
+					},
+					start: {
+						character: 1,
+						line: 0,
+					},
+				},
+				severity: 1,
+				source: 'Stylelint',
+			},
+		]);
 	});
 
 	test('should reject with a reason when it takes incorrect options', async () => {