diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart
index 82bc12b0c..5e73a106a 100644
--- a/lib/src/js/parser.dart
+++ b/lib/src/js/parser.dart
@@ -10,9 +10,12 @@ import 'package:path/path.dart' as p;
import 'package:source_span/source_span.dart';
import '../ast/sass.dart';
+import '../exception.dart';
+import '../parse/parser.dart';
import '../syntax.dart';
import '../util/nullable.dart';
import '../util/span.dart';
+import '../util/string.dart';
import '../visitor/interface/expression.dart';
import '../visitor/interface/statement.dart';
import 'reflection.dart';
@@ -24,10 +27,14 @@ import 'visitor/statement.dart';
class ParserExports {
external factory ParserExports(
{required Function parse,
+ required Function parseIdentifier,
+ required Function toCssIdentifier,
required Function createExpressionVisitor,
required Function createStatementVisitor});
external set parse(Function function);
+ external set parseIdentifier(Function function);
+ external set toCssIdentifier(Function function);
external set createStatementVisitor(Function function);
external set createExpressionVisitor(Function function);
}
@@ -45,6 +52,8 @@ ParserExports loadParserExports() {
_updateAstPrototypes();
return ParserExports(
parse: allowInterop(_parse),
+ parseIdentifier: allowInterop(_parseIdentifier),
+ toCssIdentifier: allowInterop(_toCssIdentifier),
createExpressionVisitor: allowInterop(
(JSExpressionVisitorObject inner) => JSExpressionVisitor(inner)),
createStatementVisitor: allowInterop(
@@ -117,3 +126,18 @@ Stylesheet _parse(String css, String syntax, String? path) => Stylesheet.parse(
_ => throw UnsupportedError('Unknown syntax "$syntax"')
},
url: path.andThen(p.toUri));
+
+/// A JavaScript-friendly method to parse an identifier to its semantic value.
+///
+/// Returns null if [identifier] isn't a valid identifier.
+String? _parseIdentifier(String identifier) {
+ try {
+ return Parser.parseIdentifier(identifier);
+ } on SassFormatException {
+ return null;
+ }
+}
+
+/// A JavaScript-friendly method to convert text to a valid CSS identifier with
+/// the same contents.
+String _toCssIdentifier(String text) => text.toCssIdentifier();
diff --git a/lib/src/util/character.dart b/lib/src/util/character.dart
index 7141be67a..614fec45a 100644
--- a/lib/src/util/character.dart
+++ b/lib/src/util/character.dart
@@ -10,6 +10,11 @@ import 'package:charcode/charcode.dart';
/// lowercase equivalents.
const _asciiCaseBit = 0x20;
+/// The highest character allowed in CSS.
+///
+/// See https://drafts.csswg.org/css-syntax-3/#maximum-allowed-code-point
+const maxAllowedCharacter = 0x10FFFF;
+
// Define these checks as extension getters so they can be used in pattern
// matches.
extension CharacterExtension on int {
@@ -35,6 +40,12 @@ extension CharacterExtension on int {
// 0x36 == 0b110110.
this >> 10 == 0x36;
+ /// Returns whether [character] is the end of a UTF-16 surrogate pair.
+ bool get isLowSurrogate =>
+ // A character is a high surrogate exactly if it matches 0b110111XXXXXXXXXX.
+ // 0x36 == 0b110111.
+ this >> 10 == 0x37;
+
/// Returns whether [character] is a Unicode private-use code point in the Basic
/// Multilingual Plane.
///
diff --git a/lib/src/util/string.dart b/lib/src/util/string.dart
new file mode 100644
index 000000000..949b9092c
--- /dev/null
+++ b/lib/src/util/string.dart
@@ -0,0 +1,108 @@
+// Copyright 2024 Google Inc. Use of this source code is governed by an
+// MIT-style license that can be found in the LICENSE file or at
+// https://opensource.org/licenses/MIT.
+
+import 'package:charcode/charcode.dart';
+import 'package:string_scanner/string_scanner.dart';
+
+import 'character.dart';
+
+extension StringExtension on String {
+ /// Returns a minimally-escaped CSS identifiers whose contents evaluates to
+ /// [text].
+ ///
+ /// Throws a [FormatException] if [text] cannot be represented as a CSS
+ /// identifier (such as the empty string).
+ String toCssIdentifier() {
+ var buffer = StringBuffer();
+ var scanner = SpanScanner(this);
+
+ void writeEscape(int character) {
+ buffer.writeCharCode($backslash);
+ buffer.write(character.toRadixString(16));
+ if (scanner.peekChar() case int(isHex: true)) {
+ buffer.writeCharCode($space);
+ }
+ }
+
+ void consumeSurrogatePair(int character) {
+ if (scanner.peekChar(1) case null || int(isLowSurrogate: false)) {
+ scanner.error(
+ "An individual surrogates can't be represented as a CSS "
+ "identifier.",
+ length: 1);
+ } else if (character.isPrivateUseHighSurrogate) {
+ writeEscape(combineSurrogates(scanner.readChar(), scanner.readChar()));
+ } else {
+ buffer.writeCharCode(scanner.readChar());
+ buffer.writeCharCode(scanner.readChar());
+ }
+ }
+
+ var doubleDash = false;
+ if (scanner.scanChar($dash)) {
+ if (scanner.isDone) return '\\2d';
+
+ buffer.writeCharCode($dash);
+
+ if (scanner.scanChar($dash)) {
+ buffer.writeCharCode($dash);
+ doubleDash = true;
+ }
+ }
+
+ if (!doubleDash) {
+ switch (scanner.peekChar()) {
+ case null:
+ scanner.error(
+ "The empty string can't be represented as a CSS identifier.");
+
+ case 0:
+ scanner.error("The U+0000 can't be represented as a CSS identifier.");
+
+ case int character when character.isHighSurrogate:
+ consumeSurrogatePair(character);
+
+ case int(isLowSurrogate: true):
+ scanner.error(
+ "An individual surrogate can't be represented as a CSS "
+ "identifier.",
+ length: 1);
+
+ case int(isNameStart: true, isPrivateUseBMP: false):
+ buffer.writeCharCode(scanner.readChar());
+
+ case _:
+ writeEscape(scanner.readChar());
+ }
+ }
+
+ loop:
+ while (true) {
+ switch (scanner.peekChar()) {
+ case null:
+ break loop;
+
+ case 0:
+ scanner.error("The U+0000 can't be represented as a CSS identifier.");
+
+ case int character when character.isHighSurrogate:
+ consumeSurrogatePair(character);
+
+ case int(isLowSurrogate: true):
+ scanner.error(
+ "An individual surrogate can't be represented as a CSS "
+ "identifier.",
+ length: 1);
+
+ case int(isName: true, isPrivateUseBMP: false):
+ buffer.writeCharCode(scanner.readChar());
+
+ case _:
+ writeEscape(scanner.readChar());
+ }
+ }
+
+ return buffer.toString();
+ }
+}
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 51e88a839..2e04afd16 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -409,7 +409,7 @@ int consumeEscapedCharacter(StringScanner scanner) {
if (scanner.peekChar().isWhitespace) scanner.readChar();
return switch (value) {
- 0 || (>= 0xD800 && <= 0xDFFF) || >= 0x10FFFF => 0xFFFD,
+ 0 || (>= 0xD800 && <= 0xDFFF) || >= maxAllowedCharacter => 0xFFFD,
_ => value
};
case _:
diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md
index eae5707f7..3c61db629 100644
--- a/pkg/sass-parser/CHANGELOG.md
+++ b/pkg/sass-parser/CHANGELOG.md
@@ -2,6 +2,8 @@
* Add `BooleanExpression` and `NumberExpression`.
+* Add support for parsing the `@use` rule.
+
## 0.4.0
* **Breaking change:** Warnings are no longer emitted during parsing, so the
diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts
index 11f9e3bd1..6b878c390 100644
--- a/pkg/sass-parser/lib/index.ts
+++ b/pkg/sass-parser/lib/index.ts
@@ -8,7 +8,20 @@ import {Root} from './src/statement/root';
import * as sassInternal from './src/sass-internal';
import {Stringifier} from './src/stringifier';
+export {
+ Configuration,
+ ConfigurationProps,
+ ConfigurationRaws,
+} from './src/configuration';
+export {
+ ConfiguredVariable,
+ ConfiguredVariableObjectProps,
+ ConfiguredVariableExpressionProps,
+ ConfiguredVariableProps,
+ ConfiguredVariableRaws,
+} from './src/configured-variable';
export {AnyNode, Node, NodeProps, NodeType} from './src/node';
+export {RawWithValue} from './src/raw-with-value';
export {
AnyExpression,
Expression,
@@ -71,6 +84,7 @@ export {
SassCommentProps,
SassCommentRaws,
} from './src/statement/sass-comment';
+export {UseRule, UseRuleProps, UseRuleRaws} from './src/statement/use-rule';
export {
AnyStatement,
AtRule,
diff --git a/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap
new file mode 100644
index 000000000..2b5609937
--- /dev/null
+++ b/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap
@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`a configured variable toJSON 1`] = `
+{
+ "expression": <"qux">,
+ "guarded": false,
+ "inputs": [
+ {
+ "css": "@use "foo" with ($baz: "qux")",
+ "hasBOM": false,
+ "id": "",
+ },
+ ],
+ "raws": {},
+ "sassType": "configured-variable",
+ "source": <1:18-1:29 in 0>,
+ "variableName": "baz",
+}
+`;
diff --git a/pkg/sass-parser/lib/src/configuration.test.ts b/pkg/sass-parser/lib/src/configuration.test.ts
new file mode 100644
index 000000000..b260efef3
--- /dev/null
+++ b/pkg/sass-parser/lib/src/configuration.test.ts
@@ -0,0 +1,427 @@
+// Copyright 2024 Google Inc. Use of this source code is governed by an
+// MIT-style license that can be found in the LICENSE file or at
+// https://opensource.org/licenses/MIT.
+
+import {
+ Configuration,
+ ConfiguredVariable,
+ StringExpression,
+ UseRule,
+ sass,
+ scss,
+} from '..';
+
+describe('a configuration map', () => {
+ let node: Configuration;
+ beforeEach(() => (node = new Configuration()));
+
+ describe('empty', () => {
+ function describeNode(
+ description: string,
+ create: () => Configuration
+ ): void {
+ describe(description, () => {
+ beforeEach(() => (node = create()));
+
+ it('has a sassType', () =>
+ expect(node.sassType.toString()).toBe('configuration'));
+
+ it('has no contents', () => {
+ expect(node.size).toBe(0);
+ expect([...node.variables()]).toEqual([]);
+ });
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => (scss.parse('@use "foo"').nodes[0] as UseRule).configuration
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => (sass.parse('@use "foo"').nodes[0] as UseRule).configuration
+ );
+
+ describe('constructed manually', () => {
+ describeNode('no args', () => new Configuration());
+
+ describeNode('variables array', () => new Configuration({variables: []}));
+
+ describeNode(
+ 'variables record',
+ () => new Configuration({variables: {}})
+ );
+ });
+
+ describeNode(
+ 'constructed from props',
+ () =>
+ new UseRule({useUrl: 'foo', configuration: {variables: []}})
+ .configuration
+ );
+ });
+
+ describe('with a variable', () => {
+ function describeNode(
+ description: string,
+ create: () => Configuration
+ ): void {
+ describe(description, () => {
+ beforeEach(() => (node = create()));
+
+ it('has a sassType', () =>
+ expect(node.sassType.toString()).toBe('configuration'));
+
+ it('contains the variable', () => {
+ expect(node.size).toBe(1);
+ const variable = [...node.variables()][0];
+ expect(variable.variableName).toEqual('bar');
+ expect(variable).toHaveStringExpression('expression', 'baz');
+ });
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () =>
+ (scss.parse('@use "foo" with ($bar: "baz")').nodes[0] as UseRule)
+ .configuration
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () =>
+ (sass.parse('@use "foo" with ($bar: "baz")').nodes[0] as UseRule)
+ .configuration
+ );
+
+ describe('constructed manually', () => {
+ describeNode(
+ 'variables array',
+ () =>
+ new Configuration({
+ variables: [
+ {variableName: 'bar', expression: {text: 'baz', quotes: true}},
+ ],
+ })
+ );
+
+ describeNode(
+ 'variables record',
+ () => new Configuration({variables: {bar: {text: 'baz', quotes: true}}})
+ );
+ });
+
+ describeNode(
+ 'constructed from props',
+ () =>
+ new UseRule({
+ useUrl: 'foo',
+ configuration: {variables: {bar: {text: 'baz', quotes: true}}},
+ }).configuration
+ );
+ });
+
+ describe('add()', () => {
+ test('with a ConfiguredVariable', () => {
+ const variable = new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ });
+ expect(node.add(variable)).toBe(node);
+ expect(node.size).toBe(1);
+ expect([...node.variables()][0]).toBe(variable);
+ expect(variable.parent).toBe(node);
+ });
+
+ test('with a ConfiguredVariableProps', () => {
+ node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}});
+ expect(node.size).toBe(1);
+ const variable = node.get('foo');
+ expect(variable?.variableName).toBe('foo');
+ expect(variable).toHaveStringExpression('expression', 'bar');
+ expect(variable?.parent).toBe(node);
+ });
+
+ test('overwrites on old variable', () => {
+ node.add({variableName: 'foo', expression: {text: 'old', quotes: true}});
+ const old = node.get('foo');
+ expect(old?.parent).toBe(node);
+
+ node.add({variableName: 'foo', expression: {text: 'new', quotes: true}});
+ expect(node.size).toBe(1);
+ expect(old?.parent).toBeUndefined();
+ expect(node.get('foo')).toHaveStringExpression('expression', 'new');
+ });
+ });
+
+ test('clear() removes all variables', () => {
+ node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}});
+ node.add({variableName: 'baz', expression: {text: 'bang', quotes: true}});
+ const foo = node.get('foo');
+ const bar = node.get('bar');
+ node.clear();
+
+ expect(node.size).toBe(0);
+ expect([...node.variables()]).toEqual([]);
+ expect(foo?.parent).toBeUndefined();
+ expect(bar?.parent).toBeUndefined();
+ });
+
+ describe('delete()', () => {
+ beforeEach(() => {
+ node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}});
+ node.add({variableName: 'baz', expression: {text: 'bang', quotes: true}});
+ });
+
+ test('removes a matching variable', () => {
+ const foo = node.get('foo');
+ expect(node.delete('foo')).toBe(true);
+ expect(foo?.parent).toBeUndefined();
+ expect(node.size).toBe(1);
+ expect(node.get('foo')).toBeUndefined();
+ });
+
+ test("doesn't remove a non-matching variable", () => {
+ expect(node.delete('bang')).toBe(false);
+ expect(node.size).toBe(2);
+ });
+ });
+
+ describe('get()', () => {
+ beforeEach(() => {
+ node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}});
+ });
+
+ test('returns a variable in the configuration', () => {
+ const variable = node.get('foo');
+ expect(variable?.variableName).toBe('foo');
+ expect(variable).toHaveStringExpression('expression', 'bar');
+ });
+
+ test('returns undefined for a variable not in the configuration', () => {
+ expect(node.get('bar')).toBeUndefined();
+ });
+ });
+
+ describe('has()', () => {
+ beforeEach(() => {
+ node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}});
+ });
+
+ test('returns true for a variable in the configuration', () =>
+ expect(node.has('foo')).toBe(true));
+
+ test('returns false for a variable not in the configuration', () =>
+ expect(node.has('bar')).toBe(false));
+ });
+
+ describe('set()', () => {
+ beforeEach(() => {
+ node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}});
+ });
+
+ describe('adds a new variable', () => {
+ function describeVariable(
+ description: string,
+ create: () => Configuration
+ ): void {
+ it(description, () => {
+ expect(create()).toBe(node);
+ expect(node.size).toBe(2);
+ const variable = node.get('baz');
+ expect(variable?.parent).toBe(node);
+ expect(variable?.variableName).toBe('baz');
+ expect(variable).toHaveStringExpression('expression', 'bang');
+ });
+ }
+
+ describeVariable('with Expression', () =>
+ node.set('baz', new StringExpression({text: 'bang', quotes: true}))
+ );
+
+ describeVariable('with ExpressionProps', () =>
+ node.set('baz', {text: 'bang', quotes: true})
+ );
+
+ describeVariable('with ConfiguredVariableObjectProps', () =>
+ node.set('baz', {expression: {text: 'bang', quotes: true}})
+ );
+ });
+
+ test('overwrites an existing variable', () => {
+ const foo = node.get('foo');
+ node.set('foo', {text: 'bang', quotes: true});
+ expect(foo?.parent).toBeUndefined();
+ });
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ describe('with default raws', () => {
+ it('empty', () => expect(new Configuration().toString()).toBe('()'));
+
+ it('with variables', () =>
+ expect(
+ new Configuration({
+ variables: {
+ foo: {text: 'bar', quotes: true},
+ baz: {text: 'bang', quotes: true},
+ },
+ }).toString()
+ ).toBe('($foo: "bar", $baz: "bang")'));
+ });
+
+ it('with comma: true', () =>
+ expect(
+ new Configuration({
+ raws: {comma: true},
+ variables: {
+ foo: {text: 'bar', quotes: true},
+ baz: {text: 'bang', quotes: true},
+ },
+ }).toString()
+ ).toBe('($foo: "bar", $baz: "bang",)'));
+
+ it('with comma: true and afterValue', () =>
+ expect(
+ new Configuration({
+ raws: {comma: true},
+ variables: {
+ foo: {text: 'bar', quotes: true},
+ baz: {
+ expression: {text: 'bang', quotes: true},
+ raws: {afterValue: '/**/'},
+ },
+ },
+ }).toString()
+ ).toBe('($foo: "bar", $baz: "bang"/**/,)'));
+
+ it('with after', () =>
+ expect(
+ new Configuration({
+ raws: {after: '/**/'},
+ variables: {
+ foo: {text: 'bar', quotes: true},
+ baz: {text: 'bang', quotes: true},
+ },
+ }).toString()
+ ).toBe('($foo: "bar", $baz: "bang"/**/)'));
+
+ it('with after and afterValue', () =>
+ expect(
+ new Configuration({
+ raws: {after: '/**/'},
+ variables: {
+ foo: {text: 'bar', quotes: true},
+ baz: {
+ expression: {text: 'bang', quotes: true},
+ raws: {afterValue: ' '},
+ },
+ },
+ }).toString()
+ ).toBe('($foo: "bar", $baz: "bang" /**/)'));
+
+ it('with afterValue and a guard', () =>
+ expect(
+ new Configuration({
+ variables: {
+ foo: {text: 'bar', quotes: true},
+ baz: {
+ expression: {text: 'bang', quotes: true},
+ raws: {afterValue: '/**/'},
+ guarded: true,
+ },
+ },
+ }).toString()
+ ).toBe('($foo: "bar", $baz: "bang" !default/**/)'));
+ });
+ });
+
+ describe('clone', () => {
+ let original: Configuration;
+ beforeEach(() => {
+ original = (
+ scss.parse('@use "foo" with ($foo: "bar", $baz: "bang")')
+ .nodes[0] as UseRule
+ ).configuration;
+ // TODO: remove this once raws are properly parsed
+ original.raws.after = ' ';
+ });
+
+ describe('with no overrides', () => {
+ let clone: Configuration;
+ beforeEach(() => void (clone = original.clone()));
+
+ describe('has the same properties:', () => {
+ it('variables', () => {
+ expect(clone.size).toBe(2);
+ const variables = [...clone.variables()];
+ expect(variables[0]?.variableName).toBe('foo');
+ expect(variables[0]?.parent).toBe(clone);
+ expect(variables[0]).toHaveStringExpression('expression', 'bar');
+ expect(variables[1]?.variableName).toBe('baz');
+ expect(variables[1]?.parent).toBe(clone);
+ expect(variables[1]).toHaveStringExpression('expression', 'bang');
+ });
+
+ it('raws', () => expect(clone.raws.after).toBe(' '));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of ['raws'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+ });
+
+ describe('overrides', () => {
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {comma: true}}).raws).toEqual({
+ comma: true,
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ after: ' ',
+ }));
+ });
+
+ describe('variables', () => {
+ it('defined', () => {
+ const clone = original.clone({
+ variables: {zip: {text: 'zap', quotes: true}},
+ });
+ expect(clone.size).toBe(1);
+ const variables = [...clone.variables()];
+ expect(variables[0]?.variableName).toBe('zip');
+ expect(variables[0]?.parent).toBe(clone);
+ expect(variables[0]).toHaveStringExpression('expression', 'zap');
+ });
+
+ it('undefined', () => {
+ const clone = original.clone({variables: undefined});
+ expect(clone.size).toBe(2);
+ const variables = [...clone.variables()];
+ expect(variables[0]?.variableName).toBe('foo');
+ expect(variables[0]?.parent).toBe(clone);
+ expect(variables[0]).toHaveStringExpression('expression', 'bar');
+ expect(variables[1]?.variableName).toBe('baz');
+ expect(variables[1]?.parent).toBe(clone);
+ expect(variables[1]).toHaveStringExpression('expression', 'bang');
+ });
+ });
+ });
+ });
+
+ // Can't JSON-serialize this until we implement Configuration.source.span
+ it.skip('toJSON', () =>
+ expect(
+ (scss.parse('@use "foo" with ($baz: "qux")').nodes[0] as UseRule)
+ .configuration
+ ).toMatchSnapshot());
+});
diff --git a/pkg/sass-parser/lib/src/configuration.ts b/pkg/sass-parser/lib/src/configuration.ts
new file mode 100644
index 000000000..ae788e77f
--- /dev/null
+++ b/pkg/sass-parser/lib/src/configuration.ts
@@ -0,0 +1,200 @@
+// Copyright 2024 Google Inc. Use of this source code is governed by an
+// MIT-style license that can be found in the LICENSE file or at
+// https://opensource.org/licenses/MIT.
+
+import * as postcss from 'postcss';
+
+import {
+ ConfiguredVariable,
+ ConfiguredVariableExpressionProps,
+ ConfiguredVariableProps,
+} from './configured-variable';
+import {LazySource} from './lazy-source';
+import {Node} from './node';
+import type * as sassInternal from './sass-internal';
+import * as utils from './utils';
+import {UseRule} from './statement/use-rule';
+
+/**
+ * The set of raws supported by {@link Configuration}.
+ *
+ * @category Statement
+ */
+export interface ConfigurationRaws {
+ /** Whether the final variable has a trailing comma. */
+ comma?: boolean;
+
+ /**
+ * The whitespace between the final variable (or its trailing comma if it has
+ * one) and the closing parenthesis.
+ */
+ after?: string;
+}
+
+/**
+ * The initializer properties for {@link Configuration}.
+ *
+ * @category Statement
+ */
+export interface ConfigurationProps {
+ raws?: ConfigurationRaws;
+ variables:
+ | Record
+ | Array;
+}
+
+/**
+ * A configuration map for a `@use` or `@forward` rule.
+ *
+ * @category Statement
+ */
+export class Configuration extends Node {
+ readonly sassType = 'configuration' as const;
+ declare raws: ConfigurationRaws;
+ declare parent: UseRule | undefined; // TODO: forward as well
+
+ /** The underlying map from variable names to their values. */
+ private _variables: Map = new Map();
+
+ /** The number of variables in this configuration. */
+ get size(): number {
+ return this._variables.size;
+ }
+
+ constructor(defaults?: ConfigurationProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.ConfiguredVariable[]);
+ constructor(
+ defaults?: ConfigurationProps,
+ inner?: sassInternal.ConfiguredVariable[]
+ ) {
+ super({});
+ this.raws = defaults?.raws ?? {};
+
+ if (defaults) {
+ for (const variable of Array.isArray(defaults.variables)
+ ? defaults.variables
+ : Object.entries(defaults.variables)) {
+ this.add(variable);
+ }
+ } else if (inner) {
+ this.source = new LazySource({
+ get span(): sassInternal.FileSpan {
+ // TODO: expand inner[0] and inner.at(-1) out through `(` and `)`
+ // respectively and then combine them.
+ throw new Error('currently unsupported');
+ },
+ });
+ for (const variable of inner) {
+ this.add(new ConfiguredVariable(undefined, variable));
+ }
+ }
+ }
+
+ /**
+ * Adds {@link variable} to this configuration.
+ *
+ * If there's already a variable with that name, it's removed first.
+ */
+ add(variable: ConfiguredVariable | ConfiguredVariableProps): this {
+ const realVariable =
+ 'sassType' in variable ? variable : new ConfiguredVariable(variable);
+ realVariable.parent = this;
+ const old = this._variables.get(realVariable.variableName);
+ if (old) old.parent = undefined;
+ this._variables.set(realVariable.variableName, realVariable);
+ return this;
+ }
+
+ /** Removes all variables from this configuration. */
+ clear(): void {
+ for (const variable of this._variables.values()) {
+ variable.parent = undefined;
+ }
+ this._variables.clear();
+ }
+
+ /**
+ * Removes the variable named {@link name} from this configuration.
+ *
+ * Returns whether thee variable was removed.
+ */
+ delete(key: string): boolean {
+ const old = this._variables.get(key);
+ if (old) old.parent = undefined;
+ return this._variables.delete(key);
+ }
+
+ /**
+ * Returns the variable named {@link name} from this configuration if it
+ * contains one.
+ */
+ get(key: string): ConfiguredVariable | undefined {
+ return this._variables.get(key);
+ }
+
+ /**
+ * Returns whether this configuration has a variable named {@link name}.
+ */
+ has(key: string): boolean {
+ return this._variables.has(key);
+ }
+
+ /**
+ * Sets the variable named {@link key}. This fully overrides the previous
+ * value, so all previous raws and guarded state are discarded.
+ */
+ set(key: string, expression: ConfiguredVariableExpressionProps): this {
+ const variable = new ConfiguredVariable([key, expression]);
+ variable.parent = this;
+ const old = this._variables.get(key);
+ if (old) old.parent = undefined;
+ this._variables.set(key, variable);
+ return this;
+ }
+
+ /** Returns all the variables in this configuration. */
+ variables(): IterableIterator {
+ return this._variables.values();
+ }
+
+ clone(overrides?: Partial): Configuration {
+ // We can't use `utils.cloneNode` here because variables isn't a public
+ // field. Fortunately this class doesn't have any settable derived fields to
+ // make cloning more complicated.
+ return new Configuration({
+ raws: overrides?.raws ?? structuredClone(this.raws),
+ variables: overrides?.variables ?? [...this._variables.values()],
+ });
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(this, ['variables'], inputs);
+ }
+
+ /** @hidden */
+ toString(): string {
+ let result = '(';
+ let first = true;
+ for (const variable of this._variables.values()) {
+ if (first) {
+ result += variable.raws.before ?? '';
+ first = false;
+ } else {
+ result += ',';
+ result += variable.raws.before ?? ' ';
+ }
+ result += variable.toString();
+ result += variable.raws.afterValue ?? '';
+ }
+ return result + `${this.raws.comma ? ',' : ''}${this.raws.after ?? ''})`;
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [...this.variables()];
+ }
+}
diff --git a/pkg/sass-parser/lib/src/configured-variable.test.ts b/pkg/sass-parser/lib/src/configured-variable.test.ts
new file mode 100644
index 000000000..33a0de857
--- /dev/null
+++ b/pkg/sass-parser/lib/src/configured-variable.test.ts
@@ -0,0 +1,409 @@
+// Copyright 2024 Google Inc. Use of this source code is governed by an
+// MIT-style license that can be found in the LICENSE file or at
+// https://opensource.org/licenses/MIT.
+
+import {ConfiguredVariable, StringExpression, UseRule, sass, scss} from '..';
+
+describe('a configured variable', () => {
+ let node: ConfiguredVariable;
+ beforeEach(
+ () =>
+ void (node = new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ }))
+ );
+
+ describe('unguarded', () => {
+ function describeNode(
+ description: string,
+ create: () => ConfiguredVariable
+ ): void {
+ describe(description, () => {
+ beforeEach(() => (node = create()));
+
+ it('has a sassType', () =>
+ expect(node.sassType.toString()).toBe('configured-variable'));
+
+ it('has a name', () => expect(node.variableName).toBe('foo'));
+
+ it('has a value', () =>
+ expect(node).toHaveStringExpression('expression', 'bar'));
+
+ it("isn't guarded", () => expect(node.guarded).toBe(false));
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () =>
+ (
+ scss.parse('@use "baz" with ($foo: "bar")').nodes[0] as UseRule
+ ).configuration.get('foo')!
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () =>
+ (
+ sass.parse('@use "baz" with ($foo: "bar")').nodes[0] as UseRule
+ ).configuration.get('foo')!
+ );
+
+ describe('constructed manually', () => {
+ describe('with an array', () => {
+ describeNode(
+ 'with an Expression',
+ () =>
+ new ConfiguredVariable([
+ 'foo',
+ new StringExpression({text: 'bar', quotes: true}),
+ ])
+ );
+
+ describeNode(
+ 'with ExpressionProps',
+ () => new ConfiguredVariable(['foo', {text: 'bar', quotes: true}])
+ );
+
+ describe('with an object', () => {
+ describeNode(
+ 'with an expression',
+ () =>
+ new ConfiguredVariable([
+ 'foo',
+ {expression: new StringExpression({text: 'bar', quotes: true})},
+ ])
+ );
+
+ describeNode(
+ 'with ExpressionProps',
+ () =>
+ new ConfiguredVariable([
+ 'foo',
+ {expression: {text: 'bar', quotes: true}},
+ ])
+ );
+ });
+ });
+
+ describe('with an object', () => {
+ describeNode(
+ 'with an expression',
+ () =>
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: new StringExpression({text: 'bar', quotes: true}),
+ })
+ );
+
+ describeNode(
+ 'with ExpressionProps',
+ () =>
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ })
+ );
+ });
+ });
+ });
+
+ describe('guarded', () => {
+ function describeNode(
+ description: string,
+ create: () => ConfiguredVariable
+ ): void {
+ describe(description, () => {
+ beforeEach(() => (node = create()));
+
+ it('has a sassType', () =>
+ expect(node.sassType.toString()).toBe('configured-variable'));
+
+ it('has a name', () => expect(node.variableName).toBe('foo'));
+
+ it('has a value', () =>
+ expect(node).toHaveStringExpression('expression', 'bar'));
+
+ it('is guarded', () => expect(node.guarded).toBe(true));
+ });
+ }
+
+ // We can re-enable these once ForwardRule exists.
+ // describeNode(
+ // 'parsed as SCSS',
+ // () =>
+ // (
+ // scss.parse('@forward "baz" with ($foo: "bar" !default)')
+ // .nodes[0] as ForwardRule
+ // ).configuration.get('foo')!
+ // );
+ //
+ // describeNode(
+ // 'parsed as Sass',
+ // () =>
+ // (
+ // sass.parse('@forward "baz" with ($foo: "bar" !default)')
+ // .nodes[0] as ForwardRule
+ // ).configuration.get('foo')!
+ // );
+
+ describe('constructed manually', () => {
+ describe('with an array', () => {
+ describeNode(
+ 'with an expression',
+ () =>
+ new ConfiguredVariable([
+ 'foo',
+ {
+ expression: new StringExpression({text: 'bar', quotes: true}),
+ guarded: true,
+ },
+ ])
+ );
+
+ describeNode(
+ 'with ExpressionProps',
+ () =>
+ new ConfiguredVariable([
+ 'foo',
+ {expression: {text: 'bar', quotes: true}, guarded: true},
+ ])
+ );
+ });
+
+ describe('with an object', () => {
+ describeNode(
+ 'with an expression',
+ () =>
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: new StringExpression({text: 'bar', quotes: true}),
+ guarded: true,
+ })
+ );
+
+ describeNode(
+ 'with ExpressionProps',
+ () =>
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ guarded: true,
+ })
+ );
+ });
+ });
+ });
+
+ it('assigned a new variableName', () => {
+ node.variableName = 'baz';
+ expect(node.variableName).toBe('baz');
+ });
+
+ it('assigned a new expression', () => {
+ const old = node.expression;
+ node.expression = {text: 'baz', quotes: true};
+ expect(old.parent).toBeUndefined();
+ expect(node).toHaveStringExpression('expression', 'baz');
+ });
+
+ it('assigned a new guarded', () => {
+ node.guarded = true;
+ expect(node.guarded).toBe(true);
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ describe('with default raws', () => {
+ it('unguarded', () =>
+ expect(
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ }).toString()
+ ).toBe('$foo: "bar"'));
+
+ it('guarded', () =>
+ expect(
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ guarded: true,
+ }).toString()
+ ).toBe('$foo: "bar" !default'));
+
+ it('with a non-identifier name', () =>
+ expect(
+ new ConfiguredVariable({
+ variableName: 'f o',
+ expression: {text: 'bar', quotes: true},
+ }).toString()
+ ).toBe('$f\\20o: "bar"'));
+ });
+
+ // raws.before is only used as part of a Configuration
+ it('ignores before', () =>
+ expect(
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ raws: {before: '/**/'},
+ }).toString()
+ ).toBe('$foo: "bar"'));
+
+ it('with matching name', () =>
+ expect(
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ raws: {variableName: {raw: 'f\\6fo', value: 'foo'}},
+ }).toString()
+ ).toBe('$f\\6fo: "bar"'));
+
+ it('with non-matching name', () =>
+ expect(
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ raws: {variableName: {raw: 'f\\41o', value: 'fao'}},
+ }).toString()
+ ).toBe('$foo: "bar"'));
+
+ it('with between', () =>
+ expect(
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ raws: {between: ' : '},
+ }).toString()
+ ).toBe('$foo : "bar"'));
+
+ it('with beforeGuard and a guard', () =>
+ expect(
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ guarded: true,
+ raws: {beforeGuard: '/**/'},
+ }).toString()
+ ).toBe('$foo: "bar"/**/!default'));
+
+ it('with beforeGuard and no guard', () =>
+ expect(
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ raws: {beforeGuard: '/**/'},
+ }).toString()
+ ).toBe('$foo: "bar"'));
+
+ // raws.before is only used as part of a Configuration
+ describe('ignores afterValue', () => {
+ it('with no guard', () =>
+ expect(
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ raws: {afterValue: '/**/'},
+ }).toString()
+ ).toBe('$foo: "bar"'));
+
+ it('with a guard', () =>
+ expect(
+ new ConfiguredVariable({
+ variableName: 'foo',
+ expression: {text: 'bar', quotes: true},
+ guarded: true,
+ raws: {afterValue: '/**/'},
+ }).toString()
+ ).toBe('$foo: "bar" !default'));
+ });
+ });
+ });
+
+ describe('clone()', () => {
+ let original: ConfiguredVariable;
+ beforeEach(() => {
+ original = (
+ scss.parse('@use "foo" with ($foo: "bar")').nodes[0] as UseRule
+ ).configuration.get('foo')!;
+ original.raws.between = ' : ';
+ });
+
+ describe('with no overrides', () => {
+ let clone: ConfiguredVariable;
+ beforeEach(() => void (clone = original.clone()));
+
+ describe('has the same properties:', () => {
+ it('variableName', () => expect(clone.variableName).toBe('foo'));
+
+ it('expression', () =>
+ expect(clone).toHaveStringExpression('expression', 'bar'));
+
+ it('guarded', () => expect(clone.guarded).toBe(false));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of ['expression', 'raws'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+ });
+
+ describe('overrides', () => {
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {before: ' '}}).raws).toEqual({
+ before: ' ',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ between: ' : ',
+ }));
+ });
+
+ describe('variableName', () => {
+ it('defined', () =>
+ expect(original.clone({variableName: 'baz'}).variableName).toBe(
+ 'baz'
+ ));
+
+ it('undefined', () =>
+ expect(original.clone({variableName: undefined}).variableName).toBe(
+ 'foo'
+ ));
+ });
+
+ describe('expression', () => {
+ it('defined', () =>
+ expect(
+ original.clone({expression: {text: 'baz', quotes: true}})
+ ).toHaveStringExpression('expression', 'baz'));
+
+ it('undefined', () =>
+ expect(
+ original.clone({expression: undefined})
+ ).toHaveStringExpression('expression', 'bar'));
+ });
+
+ describe('guarded', () => {
+ it('defined', () =>
+ expect(original.clone({guarded: true}).guarded).toBe(true));
+
+ it('undefined', () =>
+ expect(original.clone({guarded: undefined}).guarded).toBe(false));
+ });
+ });
+ });
+
+ it('toJSON', () =>
+ expect(
+ (
+ scss.parse('@use "foo" with ($baz: "qux")').nodes[0] as UseRule
+ ).configuration.get('baz')
+ ).toMatchSnapshot());
+});
diff --git a/pkg/sass-parser/lib/src/configured-variable.ts b/pkg/sass-parser/lib/src/configured-variable.ts
new file mode 100644
index 000000000..82f5d12aa
--- /dev/null
+++ b/pkg/sass-parser/lib/src/configured-variable.ts
@@ -0,0 +1,188 @@
+// Copyright 2024 Google Inc. Use of this source code is governed by an
+// MIT-style license that can be found in the LICENSE file or at
+// https://opensource.org/licenses/MIT.
+
+import * as postcss from 'postcss';
+
+import {Configuration} from './configuration';
+import {convertExpression} from './expression/convert';
+import {Expression, ExpressionProps} from './expression';
+import {fromProps} from './expression/from-props';
+import {LazySource} from './lazy-source';
+import {Node} from './node';
+import * as sassInternal from './sass-internal';
+import {RawWithValue} from './raw-with-value';
+import * as utils from './utils';
+
+/**
+ * The set of raws supported by {@link ConfiguredVariable}.
+ *
+ * @category Statement
+ */
+export interface ConfiguredVariableRaws {
+ /** The whitespace before the variable name. */
+ before?: string;
+
+ /**
+ * The variable's name, not including the `$`.
+ *
+ * This may be different than {@link ConfiguredVariable.variable} if the name
+ * contains escape codes or underscores.
+ */
+ variableName?: RawWithValue;
+
+ /** The whitespace and colon between the variable name and value. */
+ between?: string;
+
+ /**
+ * The whitespace between the variable's value and the `!default` flag. If the
+ * variable doesn't have a `!default` flag, this is ignored.
+ */
+ beforeGuard?: string;
+
+ /**
+ * The space symbols between the end of the variable declaration and the comma
+ * afterwards. Always empty for a variable that doesn't have a trailing comma.
+ */
+ afterValue?: string;
+}
+
+/**
+ * The initializer properties for {@link ConfiguredVariable} passed as an
+ * options object.
+ *
+ * @category Statement
+ */
+export interface ConfiguredVariableObjectProps {
+ raws?: ConfiguredVariableRaws;
+ variableName: string;
+ expression: Expression | ExpressionProps;
+ guarded?: boolean;
+}
+
+/**
+ * Properties used to initialize a {@link ConfiguredVariable} without an
+ * explicit name. This is used when the name is given elsewhere, either in the
+ * array form of {@link ConfiguredVariableProps} or the record form of [@link
+ * ConfigurationProps}.
+ *
+ * Passing in an {@link Expression} or {@link ExpressionProps} directly always
+ * creates an unguarded {@link ConfiguredVariable}.
+ */
+export type ConfiguredVariableExpressionProps =
+ | Expression
+ | ExpressionProps
+ | Omit;
+
+/**
+ * The initializer properties for {@link ConfiguredVariable}.
+ *
+ * @category Statement
+ */
+export type ConfiguredVariableProps =
+ | ConfiguredVariableObjectProps
+ | [string, ConfiguredVariableExpressionProps];
+
+/**
+ * A single variable configured for the `with` clause of a `@use` or `@forward`
+ * rule. This is always included in a {@link Configuration}.
+ *
+ * @category Statement
+ */
+export class ConfiguredVariable extends Node {
+ readonly sassType = 'configured-variable' as const;
+ declare raws: ConfiguredVariableRaws;
+ declare parent: Configuration | undefined;
+
+ /**
+ * The variable name, not including `$`.
+ *
+ * This is the parsed and normalized value, with underscores converted to
+ * hyphens and escapes resolved to the characters they represent.
+ */
+ variableName!: string;
+
+ /** The expresison whose value the variable is assigned. */
+ get expression(): Expression {
+ return this._expression!;
+ }
+ set expression(value: Expression | ExpressionProps) {
+ if (this._expression) this._expression.parent = undefined;
+ if (!('sassType' in value)) value = fromProps(value);
+ if (value) value.parent = this;
+ this._expression = value;
+ }
+ private _expression!: Expression;
+
+ /** Whether this has a `!default` guard. */
+ guarded!: boolean;
+
+ constructor(defaults: ConfiguredVariableProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.ConfiguredVariable);
+ constructor(
+ defaults?: ConfiguredVariableProps,
+ inner?: sassInternal.ConfiguredVariable
+ ) {
+ if (Array.isArray(defaults!)) {
+ const [variableName, rest] = defaults;
+ if ('sassType' in rest || !('expression' in rest)) {
+ defaults = {
+ variableName,
+ expression: rest as Expression | ExpressionProps,
+ };
+ } else {
+ defaults = {variableName, ...rest};
+ }
+ }
+ super(defaults);
+ this.raws ??= {};
+
+ if (inner) {
+ this.source = new LazySource(inner);
+ this.variableName = inner.name;
+ this.expression = convertExpression(inner.expression);
+ this.guarded = inner.isGuarded;
+ } else {
+ this.guarded ??= false;
+ }
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(this, overrides, [
+ 'raws',
+ 'variableName',
+ 'expression',
+ 'guarded',
+ ]);
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(
+ this,
+ ['variableName', 'expression', 'guarded'],
+ inputs
+ );
+ }
+
+ /** @hidden */
+ toString(): string {
+ return (
+ '$' +
+ (this.raws.variableName?.value === this.variableName
+ ? this.raws.variableName.raw
+ : sassInternal.toCssIdentifier(this.variableName)) +
+ (this.raws.between ?? ': ') +
+ this.expression +
+ (this.guarded ? `${this.raws.beforeGuard ?? ' '}!default` : '')
+ );
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [this.expression];
+ }
+}
diff --git a/pkg/sass-parser/lib/src/interpolation.ts b/pkg/sass-parser/lib/src/interpolation.ts
index 8ffbb94a2..032c564dd 100644
--- a/pkg/sass-parser/lib/src/interpolation.ts
+++ b/pkg/sass-parser/lib/src/interpolation.ts
@@ -9,6 +9,7 @@ import {fromProps} from './expression/from-props';
import {Expression, ExpressionProps} from './expression';
import {LazySource} from './lazy-source';
import {Node} from './node';
+import {RawWithValue} from './raw-with-value';
import type * as sassInternal from './sass-internal';
import * as utils from './utils';
@@ -48,13 +49,10 @@ export interface InterpolationRaws {
* The text written in the stylesheet for the plain-text portions of the
* interpolation, without any interpretation of escape sequences.
*
- * `raw` is the value of the raw itself, and `value` is the parsed value
- * that's required to be in the interpolation in order for this raw to be used.
- *
* Any indices for which {@link Interpolation.nodes} doesn't contain a string
* are ignored.
*/
- text?: Array<{raw: string; value: string} | undefined>;
+ text?: Array | undefined>;
/**
* The whitespace before and after each interpolated expression.
diff --git a/pkg/sass-parser/lib/src/node.d.ts b/pkg/sass-parser/lib/src/node.d.ts
index 8841a46a0..432983699 100644
--- a/pkg/sass-parser/lib/src/node.d.ts
+++ b/pkg/sass-parser/lib/src/node.d.ts
@@ -18,7 +18,12 @@ export type AnyNode = AnyStatement | AnyExpression | Interpolation;
* alongside `Node.type` to disambiguate between the wide range of nodes that
* Sass parses as distinct types.
*/
-export type NodeType = StatementType | ExpressionType | 'interpolation';
+export type NodeType =
+ | StatementType
+ | ExpressionType
+ | 'interpolation'
+ | 'configuration'
+ | 'configured-variable';
/** The constructor properties shared by all Sass AST nodes. */
export type NodeProps = postcss.NodeProps;
diff --git a/pkg/sass-parser/lib/src/raw-with-value.ts b/pkg/sass-parser/lib/src/raw-with-value.ts
new file mode 100644
index 000000000..3e58021a0
--- /dev/null
+++ b/pkg/sass-parser/lib/src/raw-with-value.ts
@@ -0,0 +1,26 @@
+// Copyright 2024 Google Inc. Use of this source code is governed by an
+// MIT-style license that can be found in the LICENSE file or at
+// https://opensource.org/licenses/MIT.
+
+/**
+ * An object describing how a value is represented in a stylesheet's source.
+ *
+ * This is used for values that can have multiple different representations that
+ * all produce the same value. The {@link raw} field indicates the textual
+ * representation in the stylesheet, while the {@link value} indicates the value
+ * it represents.
+ *
+ * When serializing, if {@link value} doesn't match the value in the AST node,
+ * this is ignored. This ensures that if a plugin overwrites the AST value
+ * and ignores the raws, its change is preserved in the serialized output.
+ */
+export interface RawWithValue {
+ /** The textual representation of {@link value} in the stylesheet. */
+ raw: string;
+
+ /**
+ * The parsed value that {@link raw} represents. This is used to verify that
+ * this raw is still valid for the AST node that contains it.
+ */
+ value: T;
+}
diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts
index b0b42c1a5..8c7093daa 100644
--- a/pkg/sass-parser/lib/src/sass-internal.ts
+++ b/pkg/sass-parser/lib/src/sass-internal.ts
@@ -30,6 +30,13 @@ export interface SourceFile {
declare namespace SassInternal {
function parse(css: string, syntax: Syntax, path?: string): Stylesheet;
+ function parseIdentifier(
+ identifier: string,
+ logger?: sass.Logger
+ ): string | null;
+
+ function toCssIdentifier(text: string): string;
+
class StatementVisitor {
private _fakePropertyToMakeThisAUniqueType1: T;
}
@@ -167,6 +174,18 @@ declare namespace SassInternal {
toInterpolation(): Interpolation;
}
+ class UseRule extends Statement {
+ readonly url: Object;
+ readonly namespace: string | null;
+ readonly configuration: ConfiguredVariable[];
+ }
+
+ class ConfiguredVariable extends SassNode {
+ readonly name: string;
+ readonly expression: Expression;
+ readonly isGuarded: boolean;
+ }
+
class Expression extends SassNode {
accept(visitor: ExpressionVisitor): T;
}
@@ -218,6 +237,8 @@ export type SilentComment = SassInternal.SilentComment;
export type Stylesheet = SassInternal.Stylesheet;
export type StyleRule = SassInternal.StyleRule;
export type SupportsRule = SassInternal.SupportsRule;
+export type UseRule = SassInternal.UseRule;
+export type ConfiguredVariable = SassInternal.ConfiguredVariable;
export type Interpolation = SassInternal.Interpolation;
export type Expression = SassInternal.Expression;
export type BinaryOperationExpression = SassInternal.BinaryOperationExpression;
@@ -238,6 +259,7 @@ export interface StatementVisitorObject {
visitSilentComment(node: SilentComment): T;
visitStyleRule(node: StyleRule): T;
visitSupportsRule(node: SupportsRule): T;
+ visitUseRule(node: UseRule): T;
}
export interface ExpressionVisitorObject {
@@ -248,5 +270,7 @@ export interface ExpressionVisitorObject {
}
export const parse = sassInternal.parse;
+export const parseIdentifier = sassInternal.parseIdentifier;
+export const toCssIdentifier = sassInternal.toCssIdentifier;
export const createStatementVisitor = sassInternal.createStatementVisitor;
export const createExpressionVisitor = sassInternal.createExpressionVisitor;
diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts
index b742aff8d..10a0aae11 100644
--- a/pkg/sass-parser/lib/src/statement/index.ts
+++ b/pkg/sass-parser/lib/src/statement/index.ts
@@ -17,6 +17,7 @@ import {ErrorRule, ErrorRuleProps} from './error-rule';
import {ForRule, ForRuleProps} from './for-rule';
import {Root} from './root';
import {Rule, RuleProps} from './rule';
+import {UseRule, UseRuleProps} from './use-rule';
// TODO: Replace this with the corresponding Sass types once they're
// implemented.
@@ -47,6 +48,7 @@ export type StatementType =
| 'each-rule'
| 'for-rule'
| 'error-rule'
+ | 'use-rule'
| 'sass-comment';
/**
@@ -54,7 +56,13 @@ export type StatementType =
*
* @category Statement
*/
-export type AtRule = DebugRule | EachRule | ErrorRule | ForRule | GenericAtRule;
+export type AtRule =
+ | DebugRule
+ | EachRule
+ | ErrorRule
+ | ForRule
+ | GenericAtRule
+ | UseRule;
/**
* All Sass statements that are comments.
@@ -88,7 +96,8 @@ export type ChildProps =
| ForRuleProps
| GenericAtRuleProps
| RuleProps
- | SassCommentChildProps;
+ | SassCommentChildProps
+ | UseRuleProps;
/**
* The Sass eqivalent of PostCSS's `ContainerProps`.
@@ -175,6 +184,7 @@ const visitor = sassInternal.createStatementVisitor({
appendInternalChildren(rule, inner.children);
return rule;
},
+ visitUseRule: inner => new UseRule(undefined, inner),
});
/** Appends parsed versions of `internal`'s children to `container`. */
@@ -289,6 +299,8 @@ export function normalize(
result.push(new CssComment(node as CssCommentProps));
} else if ('silentText' in node) {
result.push(new SassComment(node));
+ } else if ('useUrl' in node) {
+ result.push(new UseRule(node));
} else {
result.push(...postcssNormalizeAndConvertToSass(self, node, sample));
}
diff --git a/pkg/sass-parser/lib/src/statement/use-rule.test.ts b/pkg/sass-parser/lib/src/statement/use-rule.test.ts
new file mode 100644
index 000000000..27a76c5ee
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/use-rule.test.ts
@@ -0,0 +1,553 @@
+// Copyright 2024 Google Inc. Use of this source code is governed by an
+// MIT-style license that can be found in the LICENSE file or at
+// https://opensource.org/licenses/MIT.
+
+import {Configuration, UseRule, sass, scss} from '../..';
+import * as utils from '../../../test/utils';
+
+describe('a @use rule', () => {
+ let node: UseRule;
+ describe('with just a URL', () => {
+ function describeNode(description: string, create: () => UseRule): void {
+ describe(description, () => {
+ beforeEach(() => (node = create()));
+
+ it('has a type', () => expect(node.type.toString()).toBe('atrule'));
+
+ it('has a sassType', () =>
+ expect(node.sassType.toString()).toBe('use-rule'));
+
+ it('has a name', () => expect(node.name.toString()).toBe('use'));
+
+ it('has a url', () => expect(node.useUrl).toBe('foo'));
+
+ it('has a default namespace', () => expect(node.namespace).toBe('foo'));
+
+ it('has an empty configuration', () => {
+ expect(node.configuration.size).toBe(0);
+ expect(node.configuration.parent).toBe(node);
+ });
+
+ it('has matching params', () => expect(node.params).toBe('"foo"'));
+
+ it('has undefined nodes', () => expect(node.nodes).toBeUndefined());
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('@use "foo"').nodes[0] as UseRule
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('@use "foo"').nodes[0] as UseRule
+ );
+
+ describeNode(
+ 'constructed manually',
+ () =>
+ new UseRule({
+ useUrl: 'foo',
+ })
+ );
+
+ describeNode('constructed from ChildProps', () =>
+ utils.fromChildProps({
+ useUrl: 'foo',
+ })
+ );
+ });
+
+ describe('with no namespace', () => {
+ function describeNode(description: string, create: () => UseRule): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a type', () => expect(node.type.toString()).toBe('atrule'));
+
+ it('has a sassType', () =>
+ expect(node.sassType.toString()).toBe('use-rule'));
+
+ it('has a name', () => expect(node.name.toString()).toBe('use'));
+
+ it('has a url', () => expect(node.useUrl).toBe('foo'));
+
+ it('has a null namespace', () => expect(node.namespace).toBeNull());
+
+ it('has an empty configuration', () => {
+ expect(node.configuration.size).toBe(0);
+ expect(node.configuration.parent).toBe(node);
+ });
+
+ it('has matching params', () => expect(node.params).toBe('"foo" as *'));
+
+ it('has undefined nodes', () => expect(node.nodes).toBeUndefined());
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('@use "foo" as *').nodes[0] as UseRule
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('@use "foo" as *').nodes[0] as UseRule
+ );
+
+ describeNode(
+ 'constructed manually',
+ () =>
+ new UseRule({
+ useUrl: 'foo',
+ namespace: null,
+ })
+ );
+
+ describeNode('constructed from ChildProps', () =>
+ utils.fromChildProps({
+ useUrl: 'foo',
+ namespace: null,
+ })
+ );
+ });
+
+ describe('with explicit namespace and configuration', () => {
+ function describeNode(description: string, create: () => UseRule): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a type', () => expect(node.type.toString()).toBe('atrule'));
+
+ it('has a sassType', () =>
+ expect(node.sassType.toString()).toBe('use-rule'));
+
+ it('has a name', () => expect(node.name.toString()).toBe('use'));
+
+ it('has a url', () => expect(node.useUrl).toBe('foo'));
+
+ it('has an explicit', () => expect(node.namespace).toBe('bar'));
+
+ it('has an empty configuration', () => {
+ expect(node.configuration.size).toBe(1);
+ expect(node.configuration.parent).toBe(node);
+ const variables = [...node.configuration.variables()];
+ expect(variables[0].variableName).toBe('baz');
+ expect(variables[0]).toHaveStringExpression('expression', 'qux');
+ });
+
+ it('has matching params', () =>
+ expect(node.params).toBe('"foo" as bar with ($baz: "qux")'));
+
+ it('has undefined nodes', () => expect(node.nodes).toBeUndefined());
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () =>
+ scss.parse('@use "foo" as bar with ($baz: "qux")').nodes[0] as UseRule
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () =>
+ sass.parse('@use "foo" as bar with ($baz: "qux")').nodes[0] as UseRule
+ );
+
+ describeNode(
+ 'constructed manually',
+ () =>
+ new UseRule({
+ useUrl: 'foo',
+ namespace: 'bar',
+ configuration: {
+ variables: {baz: {text: 'qux', quotes: true}},
+ },
+ })
+ );
+
+ describeNode('constructed from ChildProps', () =>
+ utils.fromChildProps({
+ useUrl: 'foo',
+ namespace: 'bar',
+ configuration: {
+ variables: {baz: {text: 'qux', quotes: true}},
+ },
+ })
+ );
+ });
+
+ describe('throws an error when assigned a new', () => {
+ beforeEach(() => void (node = new UseRule({useUrl: 'foo'})));
+
+ it('name', () => expect(() => (node.name = 'bar')).toThrow());
+
+ it('params', () => expect(() => (node.params = 'bar')).toThrow());
+ });
+
+ it('assigned a new url', () => {
+ node = new UseRule({useUrl: 'foo'});
+ node.useUrl = 'bar';
+ expect(node.useUrl).toBe('bar');
+ expect(node.params).toBe('"bar" as foo');
+ expect(node.defaultNamespace).toBe('bar');
+ });
+
+ it('assigned a new namespace', () => {
+ node = new UseRule({useUrl: 'foo'});
+ node.namespace = 'bar';
+ expect(node.namespace).toBe('bar');
+ expect(node.params).toBe('"foo" as bar');
+ expect(node.defaultNamespace).toBe('foo');
+ });
+
+ it('assigned a new configuration', () => {
+ node = new UseRule({useUrl: 'foo'});
+ node.configuration = new Configuration({
+ variables: {bar: {text: 'baz', quotes: true}},
+ });
+ expect(node.configuration.size).toBe(1);
+ expect(node.params).toBe('"foo" with ($bar: "baz")');
+ });
+
+ describe('defaultNamespace', () => {
+ describe('is null for', () => {
+ it('a URL without a pathname', () =>
+ expect(
+ new UseRule({useUrl: 'https://example.org'}).defaultNamespace
+ ).toBeNull());
+
+ it('a URL with a slash pathname', () =>
+ expect(
+ new UseRule({useUrl: 'https://example.org/'}).defaultNamespace
+ ).toBeNull());
+
+ it('a basename that starts with .', () =>
+ expect(new UseRule({useUrl: '.foo'}).defaultNamespace).toBeNull());
+
+ it('a fragment', () =>
+ expect(new UseRule({useUrl: '#foo'}).defaultNamespace).toBeNull());
+
+ it('a path that ends in /', () =>
+ expect(new UseRule({useUrl: 'foo/'}).defaultNamespace).toBeNull());
+
+ it('an invalid identifier', () =>
+ expect(new UseRule({useUrl: '123'}).defaultNamespace).toBeNull());
+ });
+
+ it('the basename', () =>
+ expect(new UseRule({useUrl: 'foo/bar/baz'}).defaultNamespace).toBe(
+ 'baz'
+ ));
+
+ it('without an extension', () =>
+ expect(new UseRule({useUrl: 'foo.scss'}).defaultNamespace).toBe('foo'));
+
+ it('the basename of an HTTP URL', () =>
+ expect(
+ new UseRule({useUrl: 'http://example.org/foo/bar/baz'}).defaultNamespace
+ ).toBe('baz'));
+
+ it('the basename of a file: URL', () =>
+ expect(
+ new UseRule({useUrl: 'file:///foo/bar/baz'}).defaultNamespace
+ ).toBe('baz'));
+
+ it('the basename of an unknown scheme URL', () =>
+ expect(new UseRule({useUrl: 'foo:bar/bar/qux'}).defaultNamespace).toBe(
+ 'qux'
+ ));
+
+ it('a sass: URL', () =>
+ expect(new UseRule({useUrl: 'sass:foo'}).defaultNamespace).toBe('foo'));
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ describe('with default raws', () => {
+ it('with a non-default namespace', () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ namespace: 'bar',
+ }).toString()
+ ).toBe('@use "foo" as bar;'));
+
+ it('with a non-identifier namespace', () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ namespace: ' ',
+ }).toString()
+ ).toBe('@use "foo" as \\20;'));
+
+ it('with no namespace', () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ namespace: null,
+ }).toString()
+ ).toBe('@use "foo" as *;'));
+
+ it('with configuration', () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ configuration: {
+ variables: {bar: {text: 'baz', quotes: true}},
+ },
+ }).toString()
+ ).toBe('@use "foo" with ($bar: "baz");'));
+ });
+
+ describe('with a URL raw', () => {
+ it('that matches', () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ raws: {url: {raw: "'foo'", value: 'foo'}},
+ }).toString()
+ ).toBe("@use 'foo';"));
+
+ it("that doesn't match", () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ raws: {url: {raw: "'bar'", value: 'bar'}},
+ }).toString()
+ ).toBe('@use "foo";'));
+ });
+
+ describe('with a namespace raw', () => {
+ it('that matches a string', () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ raws: {namespace: {raw: ' as foo', value: 'foo'}},
+ }).toString()
+ ).toBe('@use "foo" as foo;'));
+
+ it('that matches null', () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ namespace: null,
+ raws: {namespace: {raw: ' as *', value: null}},
+ }).toString()
+ ).toBe('@use "foo" as *;'));
+
+ it("that doesn't match", () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ raws: {url: {raw: ' as bar', value: 'bar'}},
+ }).toString()
+ ).toBe('@use "foo";'));
+ });
+
+ describe('with beforeWith', () => {
+ it('and a configuration', () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ configuration: {
+ variables: {bar: {text: 'baz', quotes: true}},
+ },
+ raws: {beforeWith: '/**/'},
+ }).toString()
+ ).toBe('@use "foo"/**/with ($bar: "baz");'));
+
+ it('and no configuration', () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ raws: {beforeWith: '/**/'},
+ }).toString()
+ ).toBe('@use "foo";'));
+ });
+
+ describe('with afterWith', () => {
+ it('and a configuration', () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ configuration: {
+ variables: {bar: {text: 'baz', quotes: true}},
+ },
+ raws: {afterWith: '/**/'},
+ }).toString()
+ ).toBe('@use "foo" with/**/($bar: "baz");'));
+
+ it('and no configuration', () =>
+ expect(
+ new UseRule({
+ useUrl: 'foo',
+ raws: {afterWith: '/**/'},
+ }).toString()
+ ).toBe('@use "foo";'));
+ });
+ });
+ });
+
+ describe('clone', () => {
+ let original: UseRule;
+ beforeEach(() => {
+ original = scss.parse('@use "foo" as bar with ($baz: "qux")')
+ .nodes[0] as UseRule;
+ // TODO: remove this once raws are properly parsed
+ original.raws.beforeWith = ' ';
+ });
+
+ describe('with no overrides', () => {
+ let clone: UseRule;
+ beforeEach(() => void (clone = original.clone()));
+
+ describe('has the same properties:', () => {
+ it('params', () =>
+ expect(clone.params).toBe('"foo" as bar with ($baz: "qux")'));
+
+ it('url', () => expect(clone.useUrl).toBe('foo'));
+
+ it('namespace', () => expect(clone.namespace).toBe('bar'));
+
+ it('configuration', () => {
+ expect(clone.configuration.size).toBe(1);
+ expect(clone.configuration.parent).toBe(clone);
+ const variables = [...clone.configuration.variables()];
+ expect(variables[0].variableName).toBe('baz');
+ expect(variables[0]).toHaveStringExpression('expression', 'qux');
+ });
+
+ it('raws', () => expect(clone.raws).toEqual({beforeWith: ' '}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of ['configuration', 'raws'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+ });
+
+ describe('overrides', () => {
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {afterWith: ' '}}).raws).toEqual({
+ afterWith: ' ',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ beforeWith: ' ',
+ }));
+ });
+
+ describe('useUrl', () => {
+ describe('defined', () => {
+ let clone: UseRule;
+ beforeEach(() => {
+ clone = original.clone({useUrl: 'flip'});
+ });
+
+ it('changes useUrl', () => expect(clone.useUrl).toBe('flip'));
+
+ it('changes params', () =>
+ expect(clone.params).toBe('"flip" as bar with ($baz: "qux")'));
+ });
+
+ describe('undefined', () => {
+ let clone: UseRule;
+ beforeEach(() => {
+ clone = original.clone({useUrl: undefined});
+ });
+
+ it('preserves useUrl', () => expect(clone.useUrl).toBe('foo'));
+
+ it('preserves params', () =>
+ expect(clone.params).toBe('"foo" as bar with ($baz: "qux")'));
+ });
+ });
+
+ describe('namespace', () => {
+ describe('defined', () => {
+ let clone: UseRule;
+ beforeEach(() => {
+ clone = original.clone({namespace: 'flip'});
+ });
+
+ it('changes namespace', () => expect(clone.namespace).toBe('flip'));
+
+ it('changes params', () =>
+ expect(clone.params).toBe('"foo" as flip with ($baz: "qux")'));
+ });
+
+ describe('null', () => {
+ let clone: UseRule;
+ beforeEach(() => {
+ clone = original.clone({namespace: null});
+ });
+
+ it('changes namespace', () => expect(clone.namespace).toBeNull());
+
+ it('changes params', () =>
+ expect(clone.params).toBe('"foo" as * with ($baz: "qux")'));
+ });
+
+ describe('undefined', () => {
+ let clone: UseRule;
+ beforeEach(() => {
+ clone = original.clone({namespace: undefined});
+ });
+
+ it('preserves namespace', () => expect(clone.namespace).toBe('bar'));
+
+ it('preserves params', () =>
+ expect(clone.params).toBe('"foo" as bar with ($baz: "qux")'));
+ });
+ });
+
+ describe('configuration', () => {
+ describe('defined', () => {
+ let clone: UseRule;
+ beforeEach(() => {
+ clone = original.clone({configuration: new Configuration()});
+ });
+
+ it('changes configuration', () =>
+ expect(clone.configuration.size).toBe(0));
+
+ it('changes params', () => expect(clone.params).toBe('"foo" as bar'));
+ });
+
+ describe('undefined', () => {
+ let clone: UseRule;
+ beforeEach(() => {
+ clone = original.clone({configuration: undefined});
+ });
+
+ it('preserves configuration', () => {
+ expect(clone.configuration.size).toBe(1);
+ expect(clone.configuration.parent).toBe(clone);
+ const variables = [...clone.configuration.variables()];
+ expect(variables[0].variableName).toBe('baz');
+ expect(variables[0]).toHaveStringExpression('expression', 'qux');
+ });
+
+ it('preserves params', () =>
+ expect(clone.params).toBe('"foo" as bar with ($baz: "qux")'));
+ });
+ });
+ });
+ });
+
+ // Can't JSON-serialize this until we implement Configuration.source.span
+ it.skip('toJSON', () =>
+ expect(
+ scss.parse('@use "foo" as bar with ($baz: "qux")').nodes[0]
+ ).toMatchSnapshot());
+});
diff --git a/pkg/sass-parser/lib/src/statement/use-rule.ts b/pkg/sass-parser/lib/src/statement/use-rule.ts
new file mode 100644
index 000000000..f64b3eee3
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/use-rule.ts
@@ -0,0 +1,209 @@
+// Copyright 2024 Google Inc. Use of this source code is governed by an
+// MIT-style license that can be found in the LICENSE file or at
+// https://opensource.org/licenses/MIT.
+
+import * as postcss from 'postcss';
+import type {AtRuleRaws} from 'postcss/lib/at-rule';
+
+import {Configuration, ConfigurationProps} from '../configuration';
+import {Expression} from '../expression';
+import {StringExpression} from '../expression/string';
+import {LazySource} from '../lazy-source';
+import {RawWithValue} from '../raw-with-value';
+import * as sassInternal from '../sass-internal';
+import * as utils from '../utils';
+import {ContainerProps, Statement, StatementWithChildren} from '.';
+import {_AtRule} from './at-rule-internal';
+import {interceptIsClean} from './intercept-is-clean';
+import * as sassParser from '../..';
+
+/**
+ * The set of raws supported by {@link UseRule}.
+ *
+ * @category Statement
+ */
+export interface UseRuleRaws extends Omit {
+ /** The representation of {@link UseRule.url}. */
+ url?: RawWithValue;
+
+ /**
+ * The text of the explicit namespace value, including `as` and any whitespace
+ * before it.
+ *
+ * Only used if {@link namespace.value} matches {@link UseRule.namespace}.
+ */
+ namespace?: RawWithValue;
+
+ /**
+ * The whitespace between the URL or namespace and the `with` keyword.
+ *
+ * Unused if the rule doesn't have a `with` clause.
+ */
+ beforeWith?: string;
+
+ /**
+ * The whitespace between the `with` keyword and the configuration map.
+ *
+ * Unused unless the rule has a non-empty configuration.
+ */
+ afterWith?: string;
+}
+
+/**
+ * The initializer properties for {@link UseRule}.
+ *
+ * @category Statement
+ */
+export type UseRuleProps = ContainerProps & {
+ raws?: UseRuleRaws;
+ useUrl: string;
+ namespace?: string | null;
+ configuration?: Configuration | ConfigurationProps;
+};
+
+/**
+ * A `@use` rule. Extends [`postcss.AtRule`].
+ *
+ * [`postcss.AtRule`]: https://postcss.org/api/#atrule
+ *
+ * @category Statement
+ */
+export class UseRule
+ extends _AtRule>
+ implements Statement
+{
+ readonly sassType = 'use-rule' as const;
+ declare parent: StatementWithChildren | undefined;
+ declare raws: UseRuleRaws;
+ declare readonly nodes: undefined;
+
+ /** The URL loaded by the `@use` rule. */
+ declare useUrl: string;
+
+ /**
+ * This rule's namespace, or `null` if the members can be accessed without a
+ * namespace.
+ *
+ * Note that this is the _semantic_ namespace for the rule, so it's set even
+ * if the namespace is inferred from the URL. When constructing a new
+ * `UseRule`, this is set to {@link defaultNamespace} by default unless an
+ * explicit `null` or string value is passed.
+ */
+ declare namespace: string | null;
+
+ /**
+ * The default namespace for {@link useUrl} if no explicit namespace is
+ * specified, or null if there's not a valid default.
+ */
+ get defaultNamespace(): string | null {
+ // Use a bogus base URL so we can parse relative URLs.
+ const url = new URL(this.useUrl, 'https://example.org/');
+ const basename = url.pathname.split('/').at(-1)!;
+ const dot = basename.indexOf('.');
+ return sassInternal.parseIdentifier(
+ dot === -1 ? basename : basename.substring(0, dot)
+ );
+ }
+
+ get name(): string {
+ return 'use';
+ }
+ set name(value: string) {
+ throw new Error("UseRule.name can't be overwritten.");
+ }
+
+ get params(): string {
+ let result =
+ this.raws.url?.value === this.useUrl
+ ? this.raws.url!.raw
+ : new StringExpression({text: this.useUrl, quotes: true}).toString();
+ const hasConfiguration = this.configuration.size > 0;
+ if (this.raws.namespace?.value === this.namespace) {
+ result += this.raws.namespace?.raw;
+ } else if (!this.namespace) {
+ result += ' as *';
+ } else if (this.defaultNamespace !== this.namespace) {
+ result += ' as ' + sassInternal.toCssIdentifier(this.namespace);
+ }
+
+ if (hasConfiguration) {
+ result +=
+ `${this.raws.beforeWith ?? ' '}with` +
+ `${this.raws.afterWith ?? ' '}${this.configuration}`;
+ }
+ return result;
+ }
+ set params(value: string | number | undefined) {
+ throw new Error("UseRule.params can't be overwritten.");
+ }
+
+ /** The variables whose defaults are set when loading this module. */
+ get configuration(): Configuration {
+ return this._configuration!;
+ }
+ set configuration(configuration: Configuration | ConfigurationProps) {
+ if (this._configuration) {
+ this._configuration.clear();
+ this._configuration.parent = undefined;
+ }
+ this._configuration =
+ 'sassType' in configuration
+ ? configuration
+ : new Configuration(configuration);
+ this._configuration.parent = this;
+ }
+ private _configuration!: Configuration;
+
+ constructor(defaults: UseRuleProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.UseRule);
+ constructor(defaults?: UseRuleProps, inner?: sassInternal.UseRule) {
+ super(defaults as unknown as postcss.AtRuleProps);
+ this.raws ??= {};
+
+ if (inner) {
+ this.source = new LazySource(inner);
+ this.useUrl = inner.url.toString();
+ this.namespace = inner.namespace ?? null;
+ this.configuration = new Configuration(undefined, inner.configuration);
+ } else {
+ this.configuration ??= new Configuration();
+ if (this.namespace === undefined) this.namespace = this.defaultNamespace;
+ }
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(this, overrides, [
+ 'raws',
+ 'useUrl',
+ 'namespace',
+ 'configuration',
+ ]);
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(
+ this,
+ ['useUrl', 'namespace', 'configuration', 'params'],
+ inputs
+ );
+ }
+
+ /** @hidden */
+ toString(
+ stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
+ .stringify
+ ): string {
+ return super.toString(stringifier);
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [...Object.values(this.configuration)];
+ }
+}
+
+interceptIsClean(UseRule);
diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts
index 46374e19d..8f5a9aa5c 100644
--- a/pkg/sass-parser/lib/src/stringifier.ts
+++ b/pkg/sass-parser/lib/src/stringifier.ts
@@ -35,6 +35,7 @@ import {ErrorRule} from './statement/error-rule';
import {GenericAtRule} from './statement/generic-at-rule';
import {Rule} from './statement/rule';
import {SassComment} from './statement/sass-comment';
+import {UseRule} from './statement/use-rule';
const PostCssStringifier = require('postcss/lib/stringifier');
@@ -72,45 +73,19 @@ export class Stringifier extends PostCssStringifier {
}
private ['debug-rule'](node: DebugRule, semicolon: boolean): void {
- this.builder(
- '@debug' +
- (node.raws.afterName ?? ' ') +
- node.debugExpression +
- (node.raws.between ?? '') +
- (semicolon ? ';' : ''),
- node
- );
+ this.sassAtRule(node, semicolon);
}
private ['each-rule'](node: EachRule): void {
- this.block(
- node,
- '@each' +
- (node.raws.afterName ?? ' ') +
- node.params +
- (node.raws.between ?? '')
- );
+ this.sassAtRule(node);
}
private ['error-rule'](node: ErrorRule, semicolon: boolean): void {
- this.builder(
- '@error' +
- (node.raws.afterName ?? ' ') +
- node.errorExpression +
- (node.raws.between ?? '') +
- (semicolon ? ';' : ''),
- node
- );
+ this.sassAtRule(node, semicolon);
}
private ['for-rule'](node: EachRule): void {
- this.block(
- node,
- '@for' +
- (node.raws.afterName ?? ' ') +
- node.params +
- (node.raws.between ?? '')
- );
+ this.sassAtRule(node);
}
private atrule(node: GenericAtRule, semicolon: boolean): void {
@@ -180,4 +155,23 @@ export class Stringifier extends PostCssStringifier {
this.builder(text, node);
}
+
+ private ['use-rule'](node: UseRule, semicolon: boolean): void {
+ this.sassAtRule(node, semicolon);
+ }
+
+ /** Helper method for non-generic Sass at-rules. */
+ private sassAtRule(node: postcss.AtRule, semicolon?: boolean): void {
+ const start =
+ '@' +
+ node.name +
+ (node.raws.afterName ?? ' ') +
+ node.params +
+ (node.raws.between ?? '');
+ if (node.nodes) {
+ this.block(node, start);
+ } else {
+ this.builder(start + (semicolon ? ';' : ''), node);
+ }
+ }
}
diff --git a/pkg/sass-parser/lib/src/utils.ts b/pkg/sass-parser/lib/src/utils.ts
index f022aca5d..f18f9816c 100644
--- a/pkg/sass-parser/lib/src/utils.ts
+++ b/pkg/sass-parser/lib/src/utils.ts
@@ -173,10 +173,10 @@ function toJsonField(
): unknown {
if (typeof value !== 'object' || value === null) {
return value;
- } else if (Array.isArray(value)) {
- return value.map((element, i) =>
- toJsonField(i.toString(), element, inputs)
- );
+ } else if (Symbol.iterator in value) {
+ return (
+ Array.isArray(value) ? value : [...(value as IterableIterator)]
+ ).map((element, i) => toJsonField(i.toString(), element, inputs));
} else if ('toJSON' in value) {
if ('sassType' in value) {
return (
diff --git a/test/util/string_test.dart b/test/util/string_test.dart
new file mode 100644
index 000000000..db8607098
--- /dev/null
+++ b/test/util/string_test.dart
@@ -0,0 +1,188 @@
+// Copyright 2024 Google Inc. Use of this source code is governed by an
+// MIT-style license that can be found in the LICENSE file or at
+// https://opensource.org/licenses/MIT.
+
+import 'package:test/test.dart';
+
+import 'package:sass/src/util/string.dart';
+import 'package:sass/src/util/map.dart';
+
+void main() {
+ group("toCssIdentifier()", () {
+ group("doesn't escape", () {
+ test('a double hyphen',
+ () => expect('--'.toCssIdentifier(), equals('--')));
+
+ group("a starting character", () {
+ const chars = {
+ 'lower-case alphabetic': 'q',
+ 'upper-case alphabetic': 'E',
+ 'an underscore': '_',
+ 'non-ASCII': 'ä',
+ 'double-width': '👭'
+ };
+
+ group("at the very beginning that's", () {
+ for (var (name, char) in chars.pairs) {
+ test(name, () => expect(char.toCssIdentifier(), equals(char)));
+ }
+ });
+
+ group("after a single hyphen that's", () {
+ for (var (name, char) in chars.pairs) {
+ test(name,
+ () => expect('-$char'.toCssIdentifier(), equals('-$char')));
+ }
+ });
+ });
+
+ group("a middle character", () {
+ const chars = {
+ 'lower-case alphabetic': 'q',
+ 'upper-case alphabetic': 'E',
+ 'numeric': '4',
+ 'an underscore': '_',
+ 'a hyphen': '-',
+ 'non-ASCII': 'ä',
+ 'double-width': '👭'
+ };
+
+ group("after a name start that's", () {
+ for (var (name, char) in chars.pairs) {
+ test(name,
+ () => expect('a$char'.toCssIdentifier(), equals('a$char')));
+ }
+ });
+
+ group("after a double hyphen that's", () {
+ for (var (name, char) in chars.pairs) {
+ test(name,
+ () => expect('--$char'.toCssIdentifier(), equals('--$char')));
+ }
+ });
+ });
+ });
+
+ group('escapes', () {
+ test('a single hyphen',
+ () => expect('-'.toCssIdentifier(), equals('\\2d')));
+
+ group('a starting character', () {
+ const chars = {
+ 'numeric': ('4', '\\34'),
+ 'non-alphanumeric ASCII': ('%', '\\25'),
+ 'a BMP private-use character': ('\ueabc', '\\eabc'),
+ 'a supplementary private-use character': ('\u{fabcd}', '\\fabcd'),
+ };
+
+ group("at the very beginning that's", () {
+ for (var (name, (char, escape)) in chars.pairs) {
+ test(name, () => expect(char.toCssIdentifier(), equals('$escape')));
+ }
+ });
+
+ group("after a single hyphen that's", () {
+ for (var (name, (char, escape)) in chars.pairs) {
+ test(name,
+ () => expect('-$char'.toCssIdentifier(), equals('-$escape')));
+ }
+ });
+ });
+
+ group('a middle character', () {
+ const chars = {
+ 'non-alphanumeric ASCII': ('%', '\\25'),
+ 'a BMP private-use character': ('\ueabc', '\\eabc'),
+ 'a supplementary private-use character': ('\u{fabcd}', '\\fabcd'),
+ };
+
+ group("after a name start that's", () {
+ for (var (name, (char, escape)) in chars.pairs) {
+ test(name,
+ () => expect('a$char'.toCssIdentifier(), equals('a$escape')));
+ }
+ });
+
+ group("after a double hyphen that's", () {
+ for (var (name, (char, escape)) in chars.pairs) {
+ test(name,
+ () => expect('--$char'.toCssIdentifier(), equals('--$escape')));
+ }
+ });
+ });
+ });
+
+ group('throws an error for', () {
+ test('the empty string',
+ () => expect(''.toCssIdentifier, throwsFormatException));
+
+ const chars = {
+ 'zero': '\u0000',
+ 'single high surrogate': '\udabc',
+ 'single low surrogate': '\udcde',
+ };
+
+ group("a starting character that's", () {
+ for (var (name, char) in chars.pairs) {
+ test(name, () => expect(char.toCssIdentifier, throwsFormatException));
+ }
+ });
+
+ group("after a hyphen that's", () {
+ for (var (name, char) in chars.pairs) {
+ test(name,
+ () => expect('-$char'.toCssIdentifier, throwsFormatException));
+ }
+ });
+
+ group("after a name start that's", () {
+ for (var (name, char) in chars.pairs) {
+ test(name,
+ () => expect('a$char'.toCssIdentifier, throwsFormatException));
+ }
+ });
+
+ group("after a double hyphen that's", () {
+ for (var (name, char) in chars.pairs) {
+ test(name,
+ () => expect('--$char'.toCssIdentifier, throwsFormatException));
+ }
+ });
+
+ group("before a body char that's", () {
+ for (var (name, char) in chars.pairs) {
+ test(name,
+ () => expect('a${char}b'.toCssIdentifier, throwsFormatException));
+ }
+ });
+ });
+
+ group('adds a space between an escape and', () {
+ test('a digit', () => expect(' 1'.toCssIdentifier(), '\\20 1'));
+
+ test('a lowercase hex letter',
+ () => expect(' b'.toCssIdentifier(), '\\20 b'));
+
+ test('an uppercase hex letter',
+ () => expect(' B'.toCssIdentifier(), '\\20 B'));
+ });
+
+ group('doesn\'t add a space between an escape and', () {
+ test(
+ 'the end of the string', () => expect(' '.toCssIdentifier(), '\\20'));
+
+ test('a lowercase non-hex letter',
+ () => expect(' g'.toCssIdentifier(), '\\20g'));
+
+ test('an uppercase non-hex letter',
+ () => expect(' G'.toCssIdentifier(), '\\20G'));
+
+ test('a hyphen', () => expect(' -'.toCssIdentifier(), '\\20-'));
+
+ test('a non-ascii character',
+ () => expect(' ä'.toCssIdentifier(), '\\20ä'));
+
+ test('another escape', () => expect(' '.toCssIdentifier(), '\\20\\20'));
+ });
+ });
+}