diff --git a/README.md b/README.md index f6566be..812ebee 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,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 diff --git a/package.json b/package.json index a88afb8..044965f 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "css-tree": "^3.0.1" }, "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/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/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"],