From 6f8efa9cd9f4496ce0f87a62176cf7628ae0786e Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" <nicholas@humanwhocodes.com> Date: Wed, 9 Apr 2025 17:11:00 -0400 Subject: [PATCH 1/2] feat: Add SCSS parsing support --- README.md | 8 ++ src/index.js | 1 + src/languages/css-language.js | 29 ++++++- src/languages/scss-syntax.js | 38 +++++++++ src/languages/scss/scss-declaration.js | 102 +++++++++++++++++++++++++ src/languages/scss/scss-stylesheet.js | 98 ++++++++++++++++++++++++ src/languages/scss/scss-value.js | 53 +++++++++++++ src/languages/scss/scss-variable.js | 31 ++++++++ tests/languages/css-language.test.js | 60 +++++++++++++++ 9 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 src/languages/scss-syntax.js create mode 100644 src/languages/scss/scss-declaration.js create mode 100644 src/languages/scss/scss-stylesheet.js create mode 100644 src/languages/scss/scss-value.js create mode 100644 src/languages/scss/scss-variable.js diff --git a/README.md b/README.md index bc5b97c..1041a94 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ em { | **Language Name** | **Description** | | ----------------- | ---------------------- | | `css` | Parse CSS stylesheets. | +| `scss` | Parse SCSS stylesheets.| In order to individually configure a language in your `eslint.config.js` file, import `@eslint/css` and configure a `language`: @@ -132,6 +133,13 @@ export default [ "css/no-empty-blocks": "error", }, }, + { + files: ["**/*.scss"], + plugins: { + css, + }, + language: "css/scss" + }, ]; ``` diff --git a/src/index.js b/src/index.js index cb6aeda..3d058d7 100644 --- a/src/index.js +++ b/src/index.js @@ -28,6 +28,7 @@ const plugin = { }, languages: { css: new CSSLanguage(), + scss: new CSSLanguage({ mode: "scss" }), }, rules: { "no-empty-blocks": noEmptyBlocks, diff --git a/src/languages/css-language.js b/src/languages/css-language.js index e14071b..3a5a754 100644 --- a/src/languages/css-language.js +++ b/src/languages/css-language.js @@ -11,11 +11,11 @@ import { parse as originalParse, lexer as originalLexer, fork, - toPlainObject, tokenTypes, } from "@eslint/css-tree"; import { CSSSourceCode } from "./css-source-code.js"; import { visitorKeys } from "./css-visitor-keys.js"; +import scss from "./scss-syntax.js" //----------------------------------------------------------------------------- // Types @@ -33,6 +33,10 @@ import { visitorKeys } from "./css-visitor-keys.js"; /** @typedef {import("@eslint/core").File} File */ /** @typedef {import("@eslint/core").FileError} FileError */ +/** + * @typedef {"css"|"scss"} LanguageMode + */ + /** * @typedef {Object} CSSLanguageOptions * @property {boolean} [tolerant] Whether to be tolerant of recoverable parsing errors. @@ -94,7 +98,13 @@ export class CSSLanguage { * @type {Record<string, string[]>} */ visitorKeys = visitorKeys; - + + /** + * The language mode. + * @type {LanguageMode} + */ + mode; + /** * The default language options. * @type {CSSLanguageOptions} @@ -102,6 +112,15 @@ export class CSSLanguage { defaultLanguageOptions = { tolerant: false, }; + + /** + * Creates a new instance of the CSSLanguage class. + * @param {Object} options The options for the language. + * @param {LanguageMode} [options.mode] The language mode to use. + */ + constructor({ mode = "css" } = {}) { + this.mode = mode; + } /** * Validates the language options. @@ -147,9 +166,11 @@ export class CSSLanguage { /** @type {FileError[]} */ const errors = []; + const syntax = this.mode === "scss" ? scss : languageOptions.customSyntax; + const { tolerant } = languageOptions; - const { parse, lexer } = languageOptions.customSyntax - ? fork(languageOptions.customSyntax) + const { parse, lexer, toPlainObject } = syntax + ? fork(syntax) : { parse: originalParse, lexer: originalLexer }; /* diff --git a/src/languages/scss-syntax.js b/src/languages/scss-syntax.js new file mode 100644 index 0000000..23de9ca --- /dev/null +++ b/src/languages/scss-syntax.js @@ -0,0 +1,38 @@ +/** + * @fileoverview SCSS syntax for CSSTree. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// imports +//----------------------------------------------------------------------------- + +import * as ScssVariable from "./scss/scss-variable.js"; +import * as ScssDeclaration from "./scss/scss-declaration.js"; +import * as ScssStyleSheet from "./scss/scss-stylesheet.js"; +import * as ScssValue from "./scss/scss-value.js"; + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** + * @import { SyntaxConfig } from "@eslint/css-tree"; + */ + +/** @type {Partial<SyntaxConfig>} */ +export default { + + atrules: { + use: { + prelude: "<string>" + } + }, + + node: { + ScssVariable, + ScssDeclaration, + Value: ScssValue, + StyleSheet: ScssStyleSheet + } +}; diff --git a/src/languages/scss/scss-declaration.js b/src/languages/scss/scss-declaration.js new file mode 100644 index 0000000..e4dd016 --- /dev/null +++ b/src/languages/scss/scss-declaration.js @@ -0,0 +1,102 @@ +/** + * @fileoverview SCSS variable node for CSSTree. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { tokenTypes } from "@eslint/css-tree"; + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const DOLLARSIGN = 0x0024; // U+0024 DOLLAR SIGN ($) + +function consumeValueRaw() { + return this.Raw(this.consumeUntilExclamationMarkOrSemicolon, true); +} + +function consumeValue() { + const startValueToken = this.tokenIndex; + const value = this.Value(); + + if (value.type !== 'Raw' && + this.eof === false && + this.tokenType !== tokenTypes.Semicolon && + this.isBalanceEdge(startValueToken) === false) { + this.error(); + } + + return value; +} + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +export const name = 'ScssDeclaration'; +export const walkContext = 'declaration'; +export const structure = { + variable: String, + value: ['Value', 'Raw'] +}; + +export function parse() { + const start = this.tokenStart; + const startToken = this.tokenIndex; + const variable = readVariable.call(this); + let value; + + this.skipSC(); + this.eat(tokenTypes.Colon); + this.skipSC(); + + if (this.parseValue) { + value = this.parseWithFallback(consumeValue, consumeValueRaw); + } else { + value = consumeValueRaw.call(this, this.tokenIndex); + } + + // Do not include semicolon to range per spec + // https://drafts.csswg.org/css-syntax/#declaration-diagram + + if (this.eof === false && + this.tokenType !== tokenTypes.Semicolon && + this.isBalanceEdge(startToken) === false) { + this.error(); + } + + // skip semicolon if present + if (this.tokenType === tokenTypes.Semicolon) { + this.next(); + } + + return { + type: name, + loc: this.getLocation(start, this.tokenStart), + variable, + value + }; +} + +export function generate(node) { + this.token(tokenTypes.Delim, '$'); + this.token(tokenTypes.Ident, node.variable); + this.token(tokenTypes.Colon, ':'); + this.node(node.value); +} + +function readVariable() { + const start = this.tokenStart; + + if (this.isDelim(DOLLARSIGN)) { + this.eat(tokenTypes.Delim); + } + + this.eat(tokenTypes.Ident); + + return this.substrToCursor(start); +} diff --git a/src/languages/scss/scss-stylesheet.js b/src/languages/scss/scss-stylesheet.js new file mode 100644 index 0000000..136ce68 --- /dev/null +++ b/src/languages/scss/scss-stylesheet.js @@ -0,0 +1,98 @@ +/** + * @fileoverview SCSS variable node for CSSTree. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { tokenTypes } from "@eslint/css-tree"; + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const EXCLAMATIONMARK = 0x0021; // U+0021 EXCLAMATION MARK (!) +const DOLLARSIGN = 0x0024; // U+0024 DOLLAR SIGN ($) + +function consumeRaw() { + return this.Raw(null, false); +} + +export const name = 'StyleSheet'; +export const walkContext = 'stylesheet'; +export const structure = { + children: [[ + 'Comment', + 'CDO', + 'CDC', + 'ScssDeclaration', + 'Atrule', + 'Rule', + 'Raw' + ]] +}; + +export function parse() { + const start = this.tokenStart; + const children = this.createList(); + let child; + + while (!this.eof) { + switch (this.tokenType) { + case tokenTypes.WhiteSpace: + this.next(); + continue; + + case tokenTypes.Comment: + // ignore comments except exclamation comments (i.e. /*! .. */) on top level + if (this.charCodeAt(this.tokenStart + 2) !== EXCLAMATIONMARK) { + this.next(); + continue; + } + + child = this.Comment(); + break; + + case tokenTypes.CDO: // <!-- + child = this.CDO(); + break; + + case tokenTypes.CDC: // --> + child = this.CDC(); + break; + + // CSS Syntax Module Level 3 + // ยง2.2 Error handling + // At the "top level" of a stylesheet, an <at-keyword-token> starts an at-rule. + case tokenTypes.AtKeyword: + child = this.parseWithFallback(this.Atrule, consumeRaw); + break; + + case tokenTypes.Delim: + if (this.charCodeAt(this.tokenStart) === DOLLARSIGN) { + child = this.parseWithFallback(this.ScssDeclaration, consumeRaw); + break; + } + + // fall through + + // Anything else starts a qualified rule ... + default: + child = this.parseWithFallback(this.Rule, consumeRaw); + } + + children.push(child); + } + + return { + type: 'StyleSheet', + loc: this.getLocation(start, this.tokenStart), + children + }; +} + +export function generate(node) { + this.children(node); +} diff --git a/src/languages/scss/scss-value.js b/src/languages/scss/scss-value.js new file mode 100644 index 0000000..615eec8 --- /dev/null +++ b/src/languages/scss/scss-value.js @@ -0,0 +1,53 @@ +/** + * @fileoverview SCSS variable node for CSSTree. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { tokenTypes } from "@eslint/css-tree"; + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const DOLLARSIGN = 0x0024; // U+0024 DOLLAR SIGN ($) + + +function getScssVariable(context) { + if (this.isDelim(DOLLARSIGN)) { + this.eat(tokenTypes.Delim); + return this.ScssVariable(); + } + + return this.scope.Value.getNode.call(this, context); +} + + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +export const name = 'Value'; +export const structure = { + children: [[]] +}; + +export function parse() { + const start = this.tokenStart; + const children = this.readSequence({ + getNode: getScssVariable + }); + + return { + type: name, + loc: this.getLocation(start, this.tokenStart), + children + }; +} + +export function generate(node) { + this.children(node); +} diff --git a/src/languages/scss/scss-variable.js b/src/languages/scss/scss-variable.js new file mode 100644 index 0000000..a91d9c7 --- /dev/null +++ b/src/languages/scss/scss-variable.js @@ -0,0 +1,31 @@ +/** + * @fileoverview SCSS variable node for CSSTree. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { tokenTypes } from "@eslint/css-tree"; + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +export const name = 'ScssVariable'; +export const structure = { + name: String +}; + +export function parse() { + return { + type: name, + loc: this.getLocation(this.tokenStart, this.tokenEnd), + name: this.consume(tokenTypes.Ident) + }; +} + +export function generate(node) { + this.token(tokenTypes.Ident, node.name); +} diff --git a/tests/languages/css-language.test.js b/tests/languages/css-language.test.js index a346644..7590e96 100644 --- a/tests/languages/css-language.test.js +++ b/tests/languages/css-language.test.js @@ -261,4 +261,64 @@ describe("CSSLanguage", () => { assert.strictEqual(sourceCode.comments.length, 1); }); }); + + describe.only("SCSS", () => { + const language = new CSSLanguage({ mode: "scss" }); + + it("should parse SCSS declaration at top level", () => { + const result = language.parse({ + body: "$foo: red;", + path: "test.scss", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.ast.type, "StyleSheet"); + + const declaration = result.ast.children[0]; + assert.strictEqual(declaration.type, "ScssDeclaration"); + assert.strictEqual(declaration.variable, "$foo"); + assert.strictEqual(declaration.value.type, "Value"); + assert.strictEqual(declaration.value.children[0].type, "Identifier"); + assert.strictEqual(declaration.value.children[0].name, "red"); + }); + + it("should parse the @use rule in SCSS", () => { + + const result = language.parse({ + body: "@use 'foo';", + path: "test.scss", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.ast.type, "StyleSheet"); + + const useRule = result.ast.children[0]; + assert.strictEqual(useRule.type, "Atrule"); + assert.strictEqual(useRule.name, "use"); + assert.strictEqual(useRule.prelude.children[0].type, "String"); + assert.strictEqual(useRule.prelude.children[0].value, "foo"); + }); + + it("should parse a variable reference in a value", () => { + + const result = language.parse({ + body: "$foo: red; a { color: $foo; }", + path: "test.scss" + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.ast.type, "StyleSheet"); + + const sccsDeclaration = result.ast.children[0]; + assert.strictEqual(sccsDeclaration.type, "ScssDeclaration"); + assert.strictEqual(sccsDeclaration.variable, "$foo"); + assert.strictEqual(sccsDeclaration.value.type, "Value"); + assert.strictEqual(sccsDeclaration.value.children[0].type, "Identifier"); + assert.strictEqual(sccsDeclaration.value.children[0].name, "red"); + + const declaration = result.ast.children[1].block.children[0]; + assert.strictEqual(declaration.value.children[0].type, "ScssVariable"); + assert.strictEqual(declaration.value.children[0].name, "foo"); + }); + }); }); From 51c34ae3ed983d693ab6c8d3afa400f739be9be0 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" <nicholas@humanwhocodes.com> Date: Thu, 10 Apr 2025 17:36:03 -0400 Subject: [PATCH 2/2] Placeholder selector --- src/languages/scss-syntax.js | 4 ++ .../scss/scss-placeholder-selector.js | 35 +++++++++++ src/languages/scss/scss-selector.js | 63 +++++++++++++++++++ tests/languages/css-language.test.js | 17 +++++ 4 files changed, 119 insertions(+) create mode 100644 src/languages/scss/scss-placeholder-selector.js create mode 100644 src/languages/scss/scss-selector.js diff --git a/src/languages/scss-syntax.js b/src/languages/scss-syntax.js index 23de9ca..acf1a7c 100644 --- a/src/languages/scss-syntax.js +++ b/src/languages/scss-syntax.js @@ -11,6 +11,8 @@ import * as ScssVariable from "./scss/scss-variable.js"; import * as ScssDeclaration from "./scss/scss-declaration.js"; import * as ScssStyleSheet from "./scss/scss-stylesheet.js"; import * as ScssValue from "./scss/scss-value.js"; +import * as ScssSelector from "./scss/scss-selector.js"; +import * as ScssPlaceholderSelector from "./scss/scss-placeholder-selector.js"; //----------------------------------------------------------------------------- // Type Definitions @@ -32,6 +34,8 @@ export default { node: { ScssVariable, ScssDeclaration, + ScssPlaceholderSelector, + Selector: ScssSelector, Value: ScssValue, StyleSheet: ScssStyleSheet } diff --git a/src/languages/scss/scss-placeholder-selector.js b/src/languages/scss/scss-placeholder-selector.js new file mode 100644 index 0000000..c95d15c --- /dev/null +++ b/src/languages/scss/scss-placeholder-selector.js @@ -0,0 +1,35 @@ +/** + * @fileoverview SCSS variable node for CSSTree. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { tokenTypes } from "@eslint/css-tree"; + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +export const name = 'ScssPlaceholderSelector'; +export const structure = { + name: String +}; + +export function parse() { + const start = this.tokenStart; + + this.eat(tokenTypes.Delim); + + return { + type: name, + loc: this.getLocation(start, this.tokenStart), + name: this.consume(tokenTypes.Ident) + }; +} + +export function generate(node) { + this.tokenize(node.name); +} diff --git a/src/languages/scss/scss-selector.js b/src/languages/scss/scss-selector.js new file mode 100644 index 0000000..827cea8 --- /dev/null +++ b/src/languages/scss/scss-selector.js @@ -0,0 +1,63 @@ +/** + * @fileoverview SCSS variable node for CSSTree. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { tokenTypes } from "@eslint/css-tree"; + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const PERCENT = 0x0025; // U+0025 PERCENT SIGN (%) + + +function getSelectorsWithScss(context) { + if (this.isDelim(PERCENT)) { + return this.ScssPlaceholderSelector(); + } + return this.scope.Selector.getNode.call(this, context); +} + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +export const name = 'Selector'; +export const structure = { + children: [[ + 'TypeSelector', + 'IdSelector', + 'ScssPlaceholderSelector', + 'ClassSelector', + 'AttributeSelector', + 'PseudoClassSelector', + 'PseudoElementSelector', + 'Combinator' + ]] +}; + +export function parse() { + const children = this.readSequence({ + getNode: getSelectorsWithScss + }); + + // nothing were consumed + if (this.getFirstListNode(children) === null) { + this.error('Selector is expected'); + } + + return { + type: name, + loc: this.getLocationFromList(children), + children + }; +} + +export function generate(node) { + this.children(node); +} diff --git a/tests/languages/css-language.test.js b/tests/languages/css-language.test.js index 7590e96..1cfa45c 100644 --- a/tests/languages/css-language.test.js +++ b/tests/languages/css-language.test.js @@ -320,5 +320,22 @@ describe("CSSLanguage", () => { assert.strictEqual(declaration.value.children[0].type, "ScssVariable"); assert.strictEqual(declaration.value.children[0].name, "foo"); }); + + it("should parse SCSS placeholder selector", () => { + + const result = language.parse({ + body: "%placeholder { color: red; }", + path: "test.scss" + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.ast.type, "StyleSheet"); + + const placeholder = result.ast.children[0]; + assert.strictEqual(placeholder.type, "Rule"); + assert.strictEqual(placeholder.prelude.type, "SelectorList"); + assert.strictEqual(placeholder.prelude.children[0].type, "Selector"); + assert.strictEqual(placeholder.prelude.children[0].children[0].name, "placeholder"); + }); }); });