Repository: eslint/css (js)

Issue:
New Rule: use-layers
### Rule details

Flag any CSS rules that are outside a `@layer` block.

### What type of rule is this?

Warns about a potential problem

### Example code

This will be flagged:

```css
/* ❌ */

p {
  color: red;
}

@media (width > 500px) {
  .box {
    padding: 12px;
  }
}
```

This is fine:

```css
/* ✅ */

@layer foo {
  p {
    color: red;
  }
}

@media (width > 500px) {
  @layer bar {
    .box {
      @layer baz {
        padding: 12px;
      }
    }
  }
}
```

Note: Nested layers can used as `@layer foo.bar {…}` and anonymous layers can be used as `@layer {…}`. Both of these are fine and should not be flagged.

Some CSS, like `@import`, is fine to keep outside a `@layer`, because the contents of the file would still be using `@layer`.

```css
@import "foo.css";
@import "bootstrap.css" layer(thirdparty);

@layer foo {…}
```

### Participation

- [ ] I am willing to submit a pull request to implement this rule.

### Additional comments

When working with [cascade layers](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Cascade_layers#creating_cascade_layers), unlayered styles are usually a mistake. It is easy to forget to wrap the styles in `@layer`, which is exactly the kind of thing a linter should help with.

This rule probably should be opt-in, because cascade layers aren't being widely used yet.

Pull request:
feat: Add use-layers rule
<!--
    Thank you for contributing!

    ESLint adheres to the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct).
-->

#### Prerequisites checklist

- [x] I have read the [contributing guidelines](https://github.com/eslint/eslint/blob/HEAD/CONTRIBUTING.md).

<!--
    Please ensure your pull request is ready:

    - Read the pull request guide (https://eslint.org/docs/latest/contribute/pull-requests)
    - Update or create tests
    - If performance-related, include a benchmark
    - Update documentation for this change (if appropriate)
-->

<!--
    The following is required for all pull requests:
-->

#### What is the purpose of this pull request?

Add a new `use-layers` rule

#### What changes did you make? (Give an overview)

- Added `use-layers` rule
- Added tests
- Added docs
- Ensured the rule is exported from the plugin

Note: This rule is *not* recommended because it will likely only be useful on a subset of CSS files in any given project.

#### Related Issues

fixes #18 

<!-- include tags like "fixes #123" or "refs #123" -->

#### Is there anything you'd like reviewers to focus on?

- ~Should `@import` require a layer by default? Right now it doesn't, but I feel like maybe it should.~ Yes!
- CSSTree is returning the wrong location for layer names. I've opened an issue for that: https://github.com/csstree/csstree/issues/309
- 
<!-- markdownlint-disable-file MD004 -->


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 974282e..5342fb1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,8 +56,6 @@ jobs:
                   node-version: "lts/*"
             - name: Install dependencies
               run: npm install
-            - name: Build
-              run: npm run build
             - name: Check Types
               run: npm run test:types
     jsr_test:
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 466df71..2be9c43 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
 {
-  ".": "0.1.0"
+  ".": "0.2.0"
 }
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53845f2..5462bd1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
 # Changelog
 
+## [0.2.0](https://github.com/eslint/css/compare/css-v0.1.0...css-v0.2.0) (2025-01-07)
+
+
+### Features
+
+* Add tolerant parsing mode ([#38](https://github.com/eslint/css/issues/38)) ([9e4b2dd](https://github.com/eslint/css/commit/9e4b2dd5ae44715ad98677b3c44ccb54a3a4fe1f)), closes [#29](https://github.com/eslint/css/issues/29)
+
+
+### Bug Fixes
+
+* add type tests ([#16](https://github.com/eslint/css/issues/16)) ([f21e090](https://github.com/eslint/css/commit/f21e090cca76a85bf6b6c89c8797c79f28d5a163))
+
 ## 0.1.0 (2024-11-26)
 
 
diff --git a/README.md b/README.md
index f6566be..c3be5a3 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,7 @@ export default [
 | [`no-empty-blocks`](./docs/rules/no-empty-blocks.md)             | Disallow empty blocks            |       yes       |
 | [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md)     | Disallow invalid at-rules        |       yes       |
 | [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties      |       yes       |
+| [`use-layers`](./docs/rules/use-layers.md)                       | Require use of layers            |       no        |
 
 <!-- Rule Table End -->
 
@@ -132,6 +133,31 @@ export default [
 ];
 ```
 
+By default, the CSS parser runs in strict mode, which reports all parsing errors. If you'd like to allow recoverable parsing errors (those that the browser automatically fixes on its own), you can set the `tolerant` option to `true`:
+
+```js
+// eslint.config.js
+import css from "@eslint/css";
+
+export default [
+	{
+		files: ["**/*.css"],
+		plugins: {
+			css,
+		},
+		language: "css/css",
+		languageOptions: {
+			tolerant: true,
+		},
+		rules: {
+			"css/no-empty-blocks": "error",
+		},
+	},
+];
+```
+
+Setting `tolerant` to `true` is necessary if you are using custom syntax, such as [PostCSS](https://postcss.org/) plugins, that aren't part of the standard CSS syntax.
+
 ## License
 
 Apache 2.0
@@ -147,8 +173,8 @@ to get your logo on our READMEs and [website](https://eslint.org/sponsors).
 <h3>Platinum Sponsors</h3>
 <p><a href="https://automattic.com"><img src="https://images.opencollective.com/automattic/d0ef3e1/logo.png" alt="Automattic" height="128"></a> <a href="https://www.airbnb.com/"><img src="https://images.opencollective.com/airbnb/d327d66/logo.png" alt="Airbnb" height="128"></a></p><h3>Gold Sponsors</h3>
 <p><a href="https://trunk.io/"><img src="https://images.opencollective.com/trunkio/fb92d60/avatar.png" alt="trunk.io" height="96"></a></p><h3>Silver Sponsors</h3>
-<p><a href="https://www.serptriumph.com/"><img src="https://images.opencollective.com/serp-triumph5/fea3074/logo.png" alt="SERP Triumph" height="64"></a> <a href="https://www.jetbrains.com/"><img src="https://images.opencollective.com/jetbrains/fe76f99/logo.png" alt="JetBrains" height="64"></a> <a href="https://liftoff.io/"><img src="https://images.opencollective.com/liftoff/5c4fa84/logo.png" alt="Liftoff" height="64"></a> <a href="https://americanexpress.io"><img src="https://avatars.githubusercontent.com/u/3853301?v=4" alt="American Express" height="64"></a> <a href="https://www.workleap.com"><img src="https://avatars.githubusercontent.com/u/53535748?u=d1e55d7661d724bf2281c1bfd33cb8f99fe2465f&v=4" alt="Workleap" height="64"></a></p><h3>Bronze Sponsors</h3>
-<p><a href="https://cybozu.co.jp/"><img src="https://images.opencollective.com/cybozu/933e46d/logo.png" alt="Cybozu" height="32"></a> <a href="https://www.wordhint.net/"><img src="https://images.opencollective.com/wordhint/be86813/avatar.png" alt="WordHint" height="32"></a> <a href="https://www.crosswordsolver.org/anagram-solver/"><img src="https://images.opencollective.com/anagram-solver/2666271/logo.png" alt="Anagram Solver" height="32"></a> <a href="https://icons8.com/"><img src="https://images.opencollective.com/icons8/7fa1641/logo.png" alt="Icons8" height="32"></a> <a href="https://discord.com"><img src="https://images.opencollective.com/discordapp/f9645d9/logo.png" alt="Discord" height="32"></a> <a href="https://www.gitbook.com"><img src="https://avatars.githubusercontent.com/u/7111340?v=4" alt="GitBook" height="32"></a> <a href="https://nx.dev"><img src="https://avatars.githubusercontent.com/u/23692104?v=4" alt="Nx" height="32"></a> <a href="https://opensource.mercedes-benz.com/"><img src="https://avatars.githubusercontent.com/u/34240465?v=4" alt="Mercedes-Benz Group" height="32"></a> <a href="https://herocoders.com"><img src="https://avatars.githubusercontent.com/u/37549774?v=4" alt="HeroCoders" height="32"></a></p>
+<p><a href="https://www.serptriumph.com/"><img src="https://images.opencollective.com/serp-triumph5/fea3074/logo.png" alt="SERP Triumph" height="64"></a> <a href="https://www.jetbrains.com/"><img src="https://images.opencollective.com/jetbrains/fe76f99/logo.png" alt="JetBrains" height="64"></a> <a href="https://liftoff.io/"><img src="https://images.opencollective.com/liftoff/5c4fa84/logo.png" alt="Liftoff" height="64"></a> <a href="https://americanexpress.io"><img src="https://avatars.githubusercontent.com/u/3853301" alt="American Express" height="64"></a></p><h3>Bronze Sponsors</h3>
+<p><a href="https://cybozu.co.jp/"><img src="https://images.opencollective.com/cybozu/933e46d/logo.png" alt="Cybozu" height="32"></a> <a href="https://www.crosswordsolver.org/anagram-solver/"><img src="https://images.opencollective.com/anagram-solver/2666271/logo.png" alt="Anagram Solver" height="32"></a> <a href="https://icons8.com/"><img src="https://images.opencollective.com/icons8/7fa1641/logo.png" alt="Icons8" height="32"></a> <a href="https://discord.com"><img src="https://images.opencollective.com/discordapp/f9645d9/logo.png" alt="Discord" height="32"></a> <a href="https://www.gitbook.com"><img src="https://avatars.githubusercontent.com/u/7111340" alt="GitBook" height="32"></a> <a href="https://nx.dev"><img src="https://avatars.githubusercontent.com/u/23692104" alt="Nx" height="32"></a> <a href="https://opensource.mercedes-benz.com/"><img src="https://avatars.githubusercontent.com/u/34240465" alt="Mercedes-Benz Group" height="32"></a> <a href="https://herocoders.com"><img src="https://avatars.githubusercontent.com/u/37549774" alt="HeroCoders" height="32"></a></p>
 <h3>Technology Sponsors</h3>
 Technology sponsors allow us to use their products and services for free as part of a contribution to the open source ecosystem and our work.
 <p><a href="https://netlify.com"><img src="https://raw.githubusercontent.com/eslint/eslint.org/main/src/assets/images/techsponsors/netlify-icon.svg" alt="Netlify" height="32"></a> <a href="https://algolia.com"><img src="https://raw.githubusercontent.com/eslint/eslint.org/main/src/assets/images/techsponsors/algolia-icon.svg" alt="Algolia" height="32"></a> <a href="https://1password.com"><img src="https://raw.githubusercontent.com/eslint/eslint.org/main/src/assets/images/techsponsors/1password-icon.svg" alt="1Password" height="32"></a></p>
diff --git a/docs/rules/use-layers.md b/docs/rules/use-layers.md
new file mode 100644
index 0000000..f396d04
--- /dev/null
+++ b/docs/rules/use-layers.md
@@ -0,0 +1,124 @@
+# use-layers
+
+Require use of layers.
+
+## Background
+
+Layers are a way to organize the cascading of rules outside of their source code order. By defining named layers and describing their order, you can ensure that rules are applied in the order that best matches your use case. Here's an example:
+
+```css
+/* establish the cascade order */
+@layer reset, base, theme;
+
+/* import styles into the reset layer */
+@import url("reset.css") layer(reset);
+
+/* Theme styles */
+@layer theme {
+	body {
+		background-color: #f0f0f0;
+		color: #333;
+	}
+}
+
+/* Base styles */
+@layer base {
+	body {
+		font-family: Arial, sans-serif;
+		line-height: 1.6;
+	}
+}
+```
+
+In general, you don't want to mix rules inside of layers with rules outside of layers because you're then dealing with two different cascade behaviors.
+
+## Rule Details
+
+This rule enforces the use of layers and warns when:
+
+1. Any rule appears outside of a `@layer` block.
+1. Any `@import` doesn't specify a layer.
+1. If any layer doesn't have a name.
+
+Examples of incorrect code:
+
+```css
+/* no layer name */
+@import url(foo.css) layer;
+
+/* no layer */
+@import url(bar.css);
+
+/* outside of layer */
+.my-style {
+	color: red;
+}
+
+/* no layer name */
+@layer {
+	a {
+		color: red;
+	}
+}
+```
+
+There are also additional options to customize the behavior of this rule.
+
+### Options
+
+This rule accepts an options object with the following properties:
+
+- `allowUnnamedLayers` (default: `false`) - Set to `true` to allow layers without names.
+- `layerNamePattern` (default: `""`) - Set to a regular expression string to validate all layer names.
+- `requireImportLayers` (default: `true`) - Set to `false` to allow `@import` rules without a layer.
+
+#### `allowUnnamedLayers: true`
+
+When `allowUnnamedLayers` is set to `true`, the following code is **correct**:
+
+```css
+/* eslint css/use-layers: ["error", { allowUnnamedLayers: true }] */
+/* no layer name */
+@import url(foo.css) layer;
+
+/* no layer name */
+@layer {
+	a {
+		color: red;
+	}
+}
+```
+
+#### `layerNamePattern`
+
+The `layerNamePattern` is a regular expression string that allows you to validate the name of layers and prevent misspellings.
+
+Here's an example of **incorrect** code:
+
+```css
+/* eslint css/use-layers: ["error", { layerNamePattern: "^(reset|theme|base)$" }] */
+/* possible typo */
+@import url(foo.css) layer(resett);
+
+/* unknown layer name */
+@layer defaults {
+	a {
+		color: red;
+	}
+}
+```
+
+#### `requireImportLayers: false`
+
+When `requireImportLayers` is set to `false`, the following code is **correct**:
+
+```css
+/* eslint css/use-layers: ["error", { requireImportLayers: false }] */
+@import url(foo.css);
+@import url(foo.css) layer;
+@import url(bar.css) layer(reset);
+```
+
+## When Not to Use It
+
+If you are defining rules without layers in a file (for example, `reset.css`) and then importing that file into a layer in another file (such as, `@import url(reset.css) layer(reset)`), then you should disable this rule in the imported file (in this example, `reset.css`). This rule is only needed in the file(s) that require layers.
diff --git a/jsr.json b/jsr.json
index 0a8cd86..72ea2cb 100644
--- a/jsr.json
+++ b/jsr.json
@@ -1,6 +1,6 @@
 {
   "name": "@eslint/css",
-  "version": "0.1.0",
+  "version": "0.2.0",
   "exports": "./dist/esm/index.js",
   "publish": {
     "include": [
diff --git a/package.json b/package.json
index 8069eb0..a0d5924 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@eslint/css",
-  "version": "0.1.0",
+  "version": "0.2.0",
   "description": "CSS linting plugin for ESLint",
   "author": "Nicholas C. Zakas",
   "type": "module",
@@ -58,7 +58,7 @@
     "fmt:check": "prettier --check .",
     "test": "mocha tests/**/*.js",
     "test:coverage": "c8 npm test",
-    "test:types": "tsc -p tests/types/tsconfig.json"
+    "test:types": "npm run build && tsc -p tests/types/tsconfig.json"
   },
   "keywords": [
     "eslint",
@@ -70,10 +70,10 @@
   "license": "Apache-2.0",
   "dependencies": {
     "@eslint/plugin-kit": "^0.2.3",
-    "css-tree": "^3.0.1"
+    "css-tree": "^3.1.0"
   },
   "devDependencies": {
-    "@eslint/core": "^0.6.0",
+    "@eslint/core": "^0.7.0",
     "@eslint/json": "^0.5.0",
     "@types/eslint": "^8.56.10",
     "c8": "^9.1.0",
diff --git a/src/index.js b/src/index.js
index b601722..233092b 100644
--- a/src/index.js
+++ b/src/index.js
@@ -13,6 +13,7 @@ import noEmptyBlocks from "./rules/no-empty-blocks.js";
 import noDuplicateImports from "./rules/no-duplicate-imports.js";
 import noInvalidProperties from "./rules/no-invalid-properties.js";
 import noInvalidAtRules from "./rules/no-invalid-at-rules.js";
+import useLayers from "./rules/use-layers.js";
 
 //-----------------------------------------------------------------------------
 // Plugin
@@ -21,7 +22,7 @@ import noInvalidAtRules from "./rules/no-invalid-at-rules.js";
 const plugin = {
 	meta: {
 		name: "@eslint/css",
-		version: "0.1.0", // x-release-please-version
+		version: "0.2.0", // x-release-please-version
 	},
 	languages: {
 		css: new CSSLanguage(),
@@ -31,6 +32,7 @@ const plugin = {
 		"no-duplicate-imports": noDuplicateImports,
 		"no-invalid-at-rules": noInvalidAtRules,
 		"no-invalid-properties": noInvalidProperties,
+		"use-layers": useLayers,
 	},
 	configs: {
 		recommended: {
diff --git a/src/languages/css-language.js b/src/languages/css-language.js
index f8165b0..79b4096 100644
--- a/src/languages/css-language.js
+++ b/src/languages/css-language.js
@@ -25,6 +25,11 @@ import { visitorKeys } from "./css-visitor-keys.js";
 /** @typedef {import("@eslint/core").File} File */
 /** @typedef {import("@eslint/core").FileError} FileError */
 
+/**
+ * @typedef {Object} CSSLanguageOptions
+ * @property {boolean} [tolerant] Whether to be tolerant of recoverable parsing errors.
+ */
+
 //-----------------------------------------------------------------------------
 // Exports
 //-----------------------------------------------------------------------------
@@ -64,21 +69,38 @@ export class CSSLanguage {
 	 */
 	visitorKeys = visitorKeys;
 
+	/**
+	 * The default language options.
+	 * @type {CSSLanguageOptions}
+	 */
+	defaultLanguageOptions = {
+		tolerant: false,
+	};
+
 	/**
 	 * Validates the language options.
-	 * @returns {void}
+	 * @param {CSSLanguageOptions} languageOptions The language options to validate.
 	 * @throws {Error} When the language options are invalid.
 	 */
-	validateLanguageOptions() {
-		// noop
+	validateLanguageOptions(languageOptions) {
+		if (
+			"tolerant" in languageOptions &&
+			typeof languageOptions.tolerant !== "boolean"
+		) {
+			throw new TypeError(
+				"Expected a boolean value for 'tolerant' option.",
+			);
+		}
 	}
 
 	/**
 	 * Parses the given file into an AST.
 	 * @param {File} file The virtual file to parse.
+	 * @param {Object} [context] The parsing context.
+	 * @param {CSSLanguageOptions} [context.languageOptions] The language options to use for parsing.
 	 * @returns {ParseResult} The result of parsing.
 	 */
-	parse(file) {
+	parse(file, { languageOptions = {} } = {}) {
 		// Note: BOM already removed
 		const text = /** @type {string} */ (file.body);
 
@@ -88,6 +110,8 @@ export class CSSLanguage {
 		/** @type {FileError[]} */
 		const errors = [];
 
+		const { tolerant } = languageOptions;
+
 		/*
 		 * Check for parsing errors first. If there's a parsing error, nothing
 		 * else can happen. However, a parsing error does not throw an error
@@ -107,8 +131,10 @@ export class CSSLanguage {
 						});
 					},
 					onParseError(error) {
-						// @ts-ignore -- types are incorrect
-						errors.push(error);
+						if (!tolerant) {
+							// @ts-ignore -- types are incorrect
+							errors.push(error);
+						}
 					},
 				}),
 			);
diff --git a/src/rules/no-duplicate-imports.js b/src/rules/no-duplicate-imports.js
index 929d51a..c1ff97b 100644
--- a/src/rules/no-duplicate-imports.js
+++ b/src/rules/no-duplicate-imports.js
@@ -10,6 +10,7 @@ export default {
 		docs: {
 			description: "Disallow duplicate @import rules",
 			recommended: true,
+			url: "https://github.com/eslint/css/blob/main/docs/rules/no-duplicate-imports.md",
 		},
 
 		messages: {
diff --git a/src/rules/no-empty-blocks.js b/src/rules/no-empty-blocks.js
index 3b3099c..d715524 100644
--- a/src/rules/no-empty-blocks.js
+++ b/src/rules/no-empty-blocks.js
@@ -10,6 +10,7 @@ export default {
 		docs: {
 			description: "Disallow empty blocks",
 			recommended: true,
+			url: "https://github.com/eslint/css/blob/main/docs/rules/no-empty-blocks.md",
 		},
 
 		messages: {
diff --git a/src/rules/no-invalid-at-rules.js b/src/rules/no-invalid-at-rules.js
index bfdd581..554c42e 100644
--- a/src/rules/no-invalid-at-rules.js
+++ b/src/rules/no-invalid-at-rules.js
@@ -49,6 +49,7 @@ export default {
 		docs: {
 			description: "Disallow invalid at-rules",
 			recommended: true,
+			url: "https://github.com/eslint/css/blob/main/docs/rules/no-invalid-at-rules.md",
 		},
 
 		messages: {
diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js
index 60b78d1..3f9f343 100644
--- a/src/rules/no-invalid-properties.js
+++ b/src/rules/no-invalid-properties.js
@@ -21,6 +21,7 @@ export default {
 		docs: {
 			description: "Disallow invalid properties",
 			recommended: true,
+			url: "https://github.com/eslint/css/blob/main/docs/rules/no-invalid-properties.md",
 		},
 
 		messages: {
diff --git a/src/rules/use-layers.js b/src/rules/use-layers.js
new file mode 100644
index 0000000..13aadb3
--- /dev/null
+++ b/src/rules/use-layers.js
@@ -0,0 +1,131 @@
+/**
+ * @fileoverview Rule to require layers in CSS.
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Rule Definition
+//-----------------------------------------------------------------------------
+
+export default {
+	meta: {
+		type: /** @type {const} */ ("problem"),
+
+		docs: {
+			description: "Require use of layers",
+			url: "https://github.com/eslint/css/blob/main/docs/rules/use-layers.md",
+		},
+
+		schema: [
+			{
+				type: "object",
+				properties: {
+					allowUnnamedLayers: {
+						type: "boolean",
+					},
+					requireImportLayers: {
+						type: "boolean",
+					},
+					layerNamePattern: {
+						type: "string",
+					},
+				},
+				additionalProperties: false,
+			},
+		],
+
+		defaultOptions: [
+			{
+				allowUnnamedLayers: false,
+				requireImportLayers: true,
+				layerNamePattern: "",
+			},
+		],
+
+		messages: {
+			missingLayer: "Expected rule to be within a layer.",
+			missingLayerName: "Expected layer to have a name.",
+			missingImportLayer: "Expected import to be within a layer.",
+			layerNameMismatch:
+				"Expected layer name '{{ name }}' to match pattern '{{pattern}}'.",
+		},
+	},
+
+	create(context) {
+		let layerDepth = 0;
+		const options = context.options[0];
+		const layerNameRegex = options.layerNamePattern
+			? new RegExp(options.layerNamePattern, "u")
+			: null;
+
+		return {
+			"Atrule[name=import]"(node) {
+				// layer, if present, must always be the second child of the prelude
+				const secondChild = node.prelude.children[1];
+				const layerNode =
+					secondChild?.name === "layer" ? secondChild : null;
+
+				if (options.requireImportLayers && !layerNode) {
+					context.report({
+						loc: node.loc,
+						messageId: "missingImportLayer",
+					});
+				}
+
+				if (layerNode) {
+					const isLayerFunction = layerNode.type === "Function";
+
+					if (!options.allowUnnamedLayers && !isLayerFunction) {
+						context.report({
+							loc: layerNode.loc,
+							messageId: "missingLayerName",
+						});
+					}
+				}
+			},
+
+			Layer(node) {
+				if (!layerNameRegex) {
+					return;
+				}
+
+				if (!layerNameRegex.test(node.name)) {
+					context.report({
+						loc: node.loc,
+						messageId: "layerNameMismatch",
+						data: {
+							name: node.name,
+							pattern: options.layerNamePattern,
+						},
+					});
+				}
+			},
+
+			"Atrule[name=layer]"(node) {
+				layerDepth++;
+
+				if (!options.allowUnnamedLayers && !node.prelude) {
+					context.report({
+						loc: node.loc,
+						messageId: "missingLayerName",
+					});
+				}
+			},
+
+			"Atrule[name=layer]:exit"() {
+				layerDepth--;
+			},
+
+			Rule(node) {
+				if (layerDepth > 0) {
+					return;
+				}
+
+				context.report({
+					loc: node.loc,
+					messageId: "missingLayer",
+				});
+			},
+		};
+	},
+};


Test patch:
diff --git a/tests/languages/css-language.test.js b/tests/languages/css-language.test.js
index 8c405a0..bc96a0b 100644
--- a/tests/languages/css-language.test.js
+++ b/tests/languages/css-language.test.js
@@ -38,7 +38,7 @@ describe("CSSLanguage", () => {
 			assert.strictEqual(result.ast.children[0].type, "Rule");
 		});
 
-		it("should return an error when parsing invalid CSS", () => {
+		it("should return an error when CSS has a recoverable error", () => {
 			const language = new CSSLanguage();
 			const result = language.parse({
 				body: "a { foo; bar: 1! }",
@@ -61,6 +61,19 @@ describe("CSSLanguage", () => {
 			assert.strictEqual(result.errors[1].column, 18);
 		});
 
+		it("should not return an error when CSS has a recoverable error and tolerant: true is used", () => {
+			const language = new CSSLanguage();
+			const result = language.parse(
+				{
+					body: "a { foo; bar: 1! }",
+					path: "test.css",
+				},
+				{ languageOptions: { tolerant: true } },
+			);
+
+			assert.strictEqual(result.ok, true);
+		});
+
 		// https://github.com/csstree/csstree/issues/301
 		it.skip("should return an error when EOF is discovered before block close", () => {
 			const language = new CSSLanguage();
diff --git a/tests/plugin/eslint.test.js b/tests/plugin/eslint.test.js
index dbfd2eb..02ae869 100644
--- a/tests/plugin/eslint.test.js
+++ b/tests/plugin/eslint.test.js
@@ -37,6 +37,72 @@ describe("Plugin", () => {
 		});
 	});
 
+	describe("languageOptions", () => {
+		const config = {
+			files: ["*.css"],
+			plugins: {
+				css,
+			},
+			language: "css/css",
+			rules: {
+				"css/no-empty-blocks": "error",
+			},
+		};
+
+		describe("tolerant", () => {
+			it("should not report a parsing error when CSS has a recoverable error and tolerant: true is used", async () => {
+				const code = "a { foo; bar: 1! }";
+
+				const eslint = new ESLint({
+					overrideConfigFile: true,
+					overrideConfig: {
+						...config,
+						languageOptions: {
+							tolerant: true,
+						},
+					},
+				});
+
+				const results = await eslint.lintText(code, {
+					filePath: "test.css",
+				});
+
+				assert.strictEqual(results.length, 1);
+				assert.strictEqual(results[0].messages.length, 0);
+			});
+
+			it("should report a parsing error when CSS has a recoverable error and tolerant is undefined", async () => {
+				const code = "a { foo; bar: 1! }";
+
+				const eslint = new ESLint({
+					overrideConfigFile: true,
+					overrideConfig: config,
+				});
+
+				const results = await eslint.lintText(code, {
+					filePath: "test.css",
+				});
+
+				assert.strictEqual(results.length, 1);
+				assert.strictEqual(results[0].messages.length, 2);
+
+				assert.strictEqual(
+					results[0].messages[0].message,
+					"Parsing error: Colon is expected",
+				);
+				assert.strictEqual(results[0].messages[0].line, 1);
+				assert.strictEqual(results[0].messages[0].column, 8);
+
+				assert.strictEqual(
+					results[0].messages[1].message,
+					"Parsing error: Identifier is expected",
+				);
+				assert.strictEqual(results[0].messages[1].line, 1);
+				assert.strictEqual(results[0].messages[1].column, 18);
+			});
+		});
+	});
+
 	describe("Configuration Comments", () => {
 		const config = {
 			files: ["*.css"],
diff --git a/tests/rules/use-layers.test.js b/tests/rules/use-layers.test.js
new file mode 100644
index 0000000..6a99e7a
--- /dev/null
+++ b/tests/rules/use-layers.test.js
@@ -0,0 +1,267 @@
+/**
+ * @fileoverview Tests for use-layers rule.
+ * @author Nicholas C. Zakas
+ */
+
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import rule from "../../src/rules/use-layers.js";
+import css from "../../src/index.js";
+import { RuleTester } from "eslint";
+import dedent from "dedent";
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+	plugins: {
+		css,
+	},
+	language: "css/css",
+});
+
+ruleTester.run("use-layers", rule, {
+	valid: [
+		"@layer bar { a { color: red; } }",
+		"@layer foo { a { color: red; } }",
+		"@import 'foo.css' layer(foo);",
+		dedent`
+			@media (min-width: 600px) {
+				@layer foo {
+					a { color: bar }
+				}
+			}
+		`,
+		dedent`
+			@media (min-width: 600px) {
+				@layer foo {
+					a { color: bar }
+					@layer bar {
+						b { color: red; }
+					}
+				}
+			}
+		`,
+		{
+			code: "@layer { a { color: red; } }",
+			options: [{ allowUnnamedLayers: true }],
+		},
+		{
+			code: "@import 'foo.css' layer;",
+			options: [{ allowUnnamedLayers: true }],
+		},
+		{
+			code: "@import 'foo.css';",
+			options: [{ requireImportLayers: false }],
+		},
+	],
+	invalid: [
+		{
+			code: "a { color: bar }",
+			errors: [
+				{
+					messageId: "missingLayer",
+					line: 1,
+					column: 1,
+					endLine: 1,
+					endColumn: 17,
+				},
+			],
+		},
+		{
+			code: dedent`
+				a { color: bar }
+				@layer { a { color: red; } }
+			`,
+			errors: [
+				{
+					messageId: "missingLayer",
+					line: 1,
+					column: 1,
+					endLine: 1,
+					endColumn: 17,
+				},
+				{
+					messageId: "missingLayerName",
+					line: 2,
+					column: 1,
+					endLine: 2,
+					endColumn: 29,
+				},
+			],
+		},
+		{
+			code: dedent`
+				@layer { a { color: red; } }
+				a { color: bar }
+			`,
+			errors: [
+				{
+					messageId: "missingLayerName",
+					line: 1,
+					column: 1,
+					endLine: 1,
+					endColumn: 29,
+				},
+				{
+					messageId: "missingLayer",
+					line: 2,
+					column: 1,
+					endLine: 2,
+					endColumn: 17,
+				},
+			],
+		},
+		{
+			code: dedent`
+				@media (min-width: 600px) {
+					a { color: bar }
+				}
+			`,
+			errors: [
+				{
+					messageId: "missingLayer",
+					line: 2,
+					column: 2,
+					endLine: 2,
+					endColumn: 18,
+				},
+			],
+		},
+		{
+			code: dedent`
+				@media (min-width: 600px) {
+					@layer foo {
+						a { color: bar }
+						@layer bar {
+							b { color: red; }
+						}
+					}
+				}
+
+				p { color: red; }
+			`,
+			errors: [
+				{
+					messageId: "missingLayer",
+					line: 10,
+					column: 1,
+					endLine: 10,
+					endColumn: 18,
+				},
+			],
+		},
+		{
+			code: "@import 'foo.css';",
+			errors: [
+				{
+					messageId: "missingImportLayer",
+					line: 1,
+					column: 1,
+					endLine: 1,
+					endColumn: 19,
+				},
+			],
+		},
+		{
+			code: "@import 'foo.css' layer;",
+			errors: [
+				{
+					messageId: "missingLayerName",
+					line: 1,
+					column: 19,
+					endLine: 1,
+					endColumn: 24,
+				},
+			],
+		},
+		{
+			code: "@layer foo, baz { a { color: bar } }",
+			options: [{ layerNamePattern: "bar" }],
+			errors: [
+				{
+					messageId: "layerNameMismatch",
+					data: {
+						name: "foo",
+						pattern: "bar",
+					},
+					line: 1,
+					column: 8,
+					endLine: 1,
+					endColumn: 11,
+				},
+				{
+					messageId: "layerNameMismatch",
+					data: {
+						name: "baz",
+						pattern: "bar",
+					},
+					line: 1,
+					column: 13,
+					endLine: 1,
+					endColumn: 16,
+				},
+			],
+		},
+		{
+			code: "@layer foo, bar, baz;",
+			options: [{ layerNamePattern: "bar" }],
+			errors: [
+				{
+					messageId: "layerNameMismatch",
+					data: {
+						name: "foo",
+						pattern: "bar",
+					},
+					line: 1,
+					column: 8,
+					endLine: 1,
+					endColumn: 11,
+				},
+				{
+					messageId: "layerNameMismatch",
+					data: {
+						name: "baz",
+						pattern: "bar",
+					},
+					line: 1,
+					column: 18,
+					endLine: 1,
+					endColumn: 21,
+				},
+			],
+		},
+		{
+			code: "@import 'foo.css' layer(baz);",
+			options: [{ layerNamePattern: "bar" }],
+			errors: [
+				{
+					messageId: "layerNameMismatch",
+					data: {
+						name: "baz",
+						pattern: "bar",
+					},
+					line: 1,
+					column: 25,
+					endLine: 1,
+					endColumn: 28,
+				},
+			],
+		},
+		{
+			code: "@import 'foo.css'",
+			errors: [
+				{
+					messageId: "missingImportLayer",
+					line: 1,
+					column: 1,
+					endLine: 1,
+					endColumn: 18,
+				},
+			],
+		},
+	],
+});