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");
+		});
 	});
 });