From f6a311b64822a8cb98d335c3cca52b0d36e3ad68 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 18 Mar 2025 11:01:19 -0400 Subject: [PATCH 1/3] fix: Catch more parse errors --- package.json | 2 +- src/languages/css-language.js | 73 ++++++++++++++++++- tests/languages/css-language.test.js | 105 ++++++++++++++++++++++++++- 3 files changed, 175 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 18819229..3dc771ad 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 5d8cd40b..123ee347 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,22 @@ import { visitorKeys } from "./css-visitor-keys.js"; * @property {SyntaxConfig} [customSyntax] Custom syntax to use for parsing. */ +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const blockOpenerTokenTypes = new Map([ + [tokenTypes.LeftCurlyBracket, "}"], + [tokenTypes.LeftParenthesis, ")"], + [tokenTypes.LeftSquareBracket, "]"], +]); + +const blockCloserTokenTypes = new Map([ + [tokenTypes.RightCurlyBracket, "{"], + [tokenTypes.RightParenthesis, "("], + [tokenTypes.RightSquareBracket, "["], +]); + //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- @@ -154,10 +171,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 */ + } + }, }), ); diff --git a/tests/languages/css-language.test.js b/tests/languages/css-language.test.js index 35bcd7c0..8bddeb58 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,107 @@ 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 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); }); }); From 0c56942aede9f59635af68b289ce471f83bf5e29 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 21 Mar 2025 14:21:41 -0400 Subject: [PATCH 2/3] Update src/languages/css-language.js Co-authored-by: Milos Djermanovic --- src/languages/css-language.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/languages/css-language.js b/src/languages/css-language.js index 123ee347..e14071b8 100644 --- a/src/languages/css-language.js +++ b/src/languages/css-language.js @@ -44,6 +44,7 @@ import { visitorKeys } from "./css-visitor-keys.js"; //----------------------------------------------------------------------------- const blockOpenerTokenTypes = new Map([ + [tokenTypes.Function, ")"], [tokenTypes.LeftCurlyBracket, "}"], [tokenTypes.LeftParenthesis, ")"], [tokenTypes.LeftSquareBracket, "]"], From 895df096fae61783b9ef433f761f5e422aab44ca Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 21 Mar 2025 14:23:26 -0400 Subject: [PATCH 3/3] Update tests --- tests/languages/css-language.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/languages/css-language.test.js b/tests/languages/css-language.test.js index 8bddeb58..a346644d 100644 --- a/tests/languages/css-language.test.js +++ b/tests/languages/css-language.test.js @@ -203,6 +203,22 @@ describe("CSSLanguage", () => { 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(