Repository: eslint/css
Pull request #97 (2025-03-20 15:44:05)

fix: Catch more parse errors
<!--
    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?

Catches some additional parsing errors

#### What changes did you make? (Give an overview)

- Added specific error messages for unclosed braces, square brackets, and parentheses (only in strict mode, hidden in tolerant mode)
- Updated tests
- Upgraded `@eslint/css-tree`
- 
#### Related Issues

<!-- include tags like "fixes #123" or "refs #123" -->

#### Is there anything you'd like reviewers to focus on?

<!-- markdownlint-disable-file MD004 -->



Patch:
diff --git a/package.json b/package.json
index 1881922..3dc771a 100644
--- a/package.json
+++ b/package.json
@@ -86,7 +86,7 @@
   "license": "Apache-2.0",
   "dependencies": {
     "@eslint/core": "^0.10.0",
-    "@eslint/css-tree": "^3.3.0",
+    "@eslint/css-tree": "^3.3.1",
     "@eslint/plugin-kit": "^0.2.5"
   },
   "devDependencies": {
diff --git a/src/languages/css-language.js b/src/languages/css-language.js
index 5d8cd40..e14071b 100644
--- a/src/languages/css-language.js
+++ b/src/languages/css-language.js
@@ -12,6 +12,7 @@ import {
 	lexer as originalLexer,
 	fork,
 	toPlainObject,
+	tokenTypes,
 } from "@eslint/css-tree";
 import { CSSSourceCode } from "./css-source-code.js";
 import { visitorKeys } from "./css-visitor-keys.js";
@@ -38,6 +39,23 @@ import { visitorKeys } from "./css-visitor-keys.js";
  * @property {SyntaxConfig} [customSyntax] Custom syntax to use for parsing.
  */
 
+//-----------------------------------------------------------------------------
+// Helpers
+//-----------------------------------------------------------------------------
+
+const blockOpenerTokenTypes = new Map([
+	[tokenTypes.Function, ")"],
+	[tokenTypes.LeftCurlyBracket, "}"],
+	[tokenTypes.LeftParenthesis, ")"],
+	[tokenTypes.LeftSquareBracket, "]"],
+]);
+
+const blockCloserTokenTypes = new Map([
+	[tokenTypes.RightCurlyBracket, "{"],
+	[tokenTypes.RightParenthesis, "("],
+	[tokenTypes.RightSquareBracket, "["],
+]);
+
 //-----------------------------------------------------------------------------
 // Exports
 //-----------------------------------------------------------------------------
@@ -154,10 +172,64 @@ export class CSSLanguage {
 					},
 					onParseError(error) {
 						if (!tolerant) {
-							// @ts-ignore -- types are incorrect
 							errors.push(error);
 						}
 					},
+					onToken(type, start, end, index) {
+						if (tolerant) {
+							return;
+						}
+
+						switch (type) {
+							// these already generate errors
+							case tokenTypes.BadString:
+							case tokenTypes.BadUrl:
+								break;
+
+							default:
+								/* eslint-disable new-cap -- This is a valid call */
+								if (this.isBlockOpenerTokenType(type)) {
+									if (
+										this.getBlockTokenPairIndex(index) ===
+										-1
+									) {
+										const loc = this.getRangeLocation(
+											start,
+											end,
+										);
+										errors.push(
+											parse.SyntaxError(
+												`Missing closing ${blockOpenerTokenTypes.get(type)}`,
+												text,
+												start,
+												loc.start.line,
+												loc.start.column,
+											),
+										);
+									}
+								} else if (this.isBlockCloserTokenType(type)) {
+									if (
+										this.getBlockTokenPairIndex(index) ===
+										-1
+									) {
+										const loc = this.getRangeLocation(
+											start,
+											end,
+										);
+										errors.push(
+											parse.SyntaxError(
+												`Missing opening ${blockCloserTokenTypes.get(type)}`,
+												text,
+												start,
+												loc.start.line,
+												loc.start.column,
+											),
+										);
+									}
+								}
+							/* eslint-enable new-cap -- This is a valid call */
+						}
+					},
 				}),
 			);
 


Test patch:
diff --git a/tests/languages/css-language.test.js b/tests/languages/css-language.test.js
index 35bcd7c..a346644 100644
--- a/tests/languages/css-language.test.js
+++ b/tests/languages/css-language.test.js
@@ -103,8 +103,7 @@ describe("CSSLanguage", () => {
 			}, /Expected an object value for 'customSyntax' option/u);
 		});
 
-		// https://github.com/csstree/csstree/issues/301
-		it.skip("should return an error when EOF is discovered before block close", () => {
+		it("should return an error when EOF is discovered before block close", () => {
 			const language = new CSSLanguage();
 			const result = language.parse({
 				body: "a {",
@@ -114,7 +113,123 @@ describe("CSSLanguage", () => {
 			assert.strictEqual(result.ok, false);
 			assert.strictEqual(result.ast, undefined);
 			assert.strictEqual(result.errors.length, 1);
-			assert.strictEqual(result.errors[0].message, "Colon is expected");
+			assert.strictEqual(result.errors[0].message, "Missing closing }");
+			assert.strictEqual(result.errors[0].line, 1);
+			assert.strictEqual(result.errors[0].column, 3);
+			assert.strictEqual(result.errors[0].offset, 2);
+		});
+
+		it("should return an error when a CSS bad string is found", () => {
+			const language = new CSSLanguage();
+			const result = language.parse({
+				body: "a { content: 'this\nstring is not properly closed' }",
+				path: "test.css",
+			});
+
+			assert.strictEqual(result.ok, false);
+			assert.strictEqual(result.ast, undefined);
+			assert.strictEqual(result.errors.length, 2);
+			assert.strictEqual(result.errors[0].message, "Missing closing }");
+			assert.strictEqual(result.errors[0].line, 1);
+			assert.strictEqual(result.errors[0].column, 3);
+			assert.strictEqual(result.errors[0].offset, 2);
+			assert.strictEqual(result.errors[1].message, "Unexpected input");
+			assert.strictEqual(result.errors[1].line, 1);
+			assert.strictEqual(result.errors[1].column, 14);
+			assert.strictEqual(result.errors[1].offset, 13);
+		});
+
+		it("should return an error when a CSS bad URL is found", () => {
+			const language = new CSSLanguage();
+			const result = language.parse({
+				body: "a { background: url(foo bar.png) }",
+				path: "test.css",
+			});
+
+			assert.strictEqual(result.ok, false);
+			assert.strictEqual(result.ast, undefined);
+			assert.strictEqual(result.errors.length, 1);
+			assert.strictEqual(result.errors[0].message, "Unexpected input");
+			assert.strictEqual(result.errors[0].line, 1);
+			assert.strictEqual(result.errors[0].column, 17);
+			assert.strictEqual(result.errors[0].offset, 16);
+		});
+
+		it("should return an error when braces are unclosed", () => {
+			const language = new CSSLanguage();
+			const result = language.parse({
+				body: "a { color: red;",
+				path: "test.css",
+			});
+
+			assert.strictEqual(result.ok, false);
+			assert.strictEqual(result.ast, undefined);
+			assert.strictEqual(result.errors.length, 1);
+			assert.strictEqual(result.errors[0].message, "Missing closing }");
+			assert.strictEqual(result.errors[0].line, 1);
+			assert.strictEqual(result.errors[0].column, 3);
+			assert.strictEqual(result.errors[0].offset, 2);
+		});
+
+		it("should return an error when square brackets are unclosed", () => {
+			const language = new CSSLanguage();
+			const result = language.parse({
+				body: "a[foo { color: red; }",
+				path: "test.css",
+			});
+
+			assert.strictEqual(result.ok, false);
+			assert.strictEqual(result.ast, undefined);
+			assert.strictEqual(result.errors.length, 3); // other errors caused by the first one
+			assert.strictEqual(result.errors[0].message, "Missing closing ]");
+			assert.strictEqual(result.errors[0].line, 1);
+			assert.strictEqual(result.errors[0].column, 2);
+			assert.strictEqual(result.errors[0].offset, 1);
+		});
+
+		it("should return an error when parentheses are unclosed", () => {
+			const language = new CSSLanguage();
+			const result = language.parse({
+				body: "@supports (color: red {}",
+				path: "test.css",
+			});
+
+			assert.strictEqual(result.ok, false);
+			assert.strictEqual(result.ast, undefined);
+			assert.strictEqual(result.errors.length, 2); // other errors caused by the first one
+			assert.strictEqual(result.errors[0].message, "Missing closing )");
+			assert.strictEqual(result.errors[0].line, 1);
+			assert.strictEqual(result.errors[0].column, 11);
+			assert.strictEqual(result.errors[0].offset, 10);
+		});
+
+		it("should return an error when function parentheses is unclosed", () => {
+			const language = new CSSLanguage();
+			const result = language.parse({
+				body: "a { width: min(40%, 400px; }",
+				path: "test.css",
+			});
+
+			assert.strictEqual(result.ok, false);
+			assert.strictEqual(result.ast, undefined);
+			assert.strictEqual(result.errors.length, 4); // other errors caused by the first one
+			assert.strictEqual(result.errors[1].message, "Missing closing )");
+			assert.strictEqual(result.errors[1].line, 1);
+			assert.strictEqual(result.errors[1].column, 12);
+			assert.strictEqual(result.errors[1].offset, 11);
+		});
+
+		it("should not return an error when braces are unclosed and tolerant: true is used", () => {
+			const language = new CSSLanguage();
+			const result = language.parse(
+				{
+					body: "a { color: red;",
+					path: "test.css",
+				},
+				{ languageOptions: { tolerant: true } },
+			);
+
+			assert.strictEqual(result.ok, true);
 		});
 	});