Skip to content

Commit

Permalink
feat: Add tolerant parsing mode
Browse files Browse the repository at this point in the history
fixes #29
  • Loading branch information
nzakas committed Jan 2, 2025
1 parent 37984bd commit 4ede7f2
Show file tree
Hide file tree
Showing 5 changed files with 138 additions and 8 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 32 additions & 6 deletions src/languages/css-language.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
//-----------------------------------------------------------------------------
Expand Down Expand Up @@ -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);

Expand All @@ -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
Expand All @@ -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);
}
},
}),
);
Expand Down
15 changes: 14 additions & 1 deletion tests/languages/css-language.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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! }",
Expand All @@ -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();
Expand Down
66 changes: 66 additions & 0 deletions tests/plugin/eslint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down

0 comments on commit 4ede7f2

Please sign in to comment.