-
+
@@ -14,6 +14,8 @@ A [Dart][dart] implementation of [Sass][sass]. **Sass makes CSS fun**.
+
+
diff --git a/lib/src/ast/sass/expression.dart b/lib/src/ast/sass/expression.dart
index 051e1c269..5028d9f87 100644
--- a/lib/src/ast/sass/expression.dart
+++ b/lib/src/ast/sass/expression.dart
@@ -13,15 +13,20 @@ import '../../value.dart';
import '../../visitor/interface/expression.dart';
import '../sass.dart';
+// Note: despite not defining any methods here, this has to be a concrete class
+// so we can expose its accept() function to the JS parser.
+
/// A SassScript expression in a Sass syntax tree.
///
/// {@category AST}
/// {@category Parsing}
@sealed
-abstract interface class Expression implements SassNode {
+abstract class Expression implements SassNode {
/// Calls the appropriate visit method on [visitor].
T accept(ExpressionVisitor visitor);
+ Expression();
+
/// Parses an expression from [contents].
///
/// If passed, [url] is the name of the file from which [contents] comes.
diff --git a/lib/src/ast/sass/expression/binary_operation.dart b/lib/src/ast/sass/expression/binary_operation.dart
index 15ab22bba..4e9cda229 100644
--- a/lib/src/ast/sass/expression/binary_operation.dart
+++ b/lib/src/ast/sass/expression/binary_operation.dart
@@ -14,7 +14,7 @@ import 'list.dart';
/// A binary operator, as in `1 + 2` or `$this and $other`.
///
/// {@category AST}
-final class BinaryOperationExpression implements Expression {
+final class BinaryOperationExpression extends Expression {
/// The operator being invoked.
final BinaryOperator operator;
@@ -111,6 +111,9 @@ final class BinaryOperationExpression implements Expression {
///
/// {@category AST}
enum BinaryOperator {
+ // Note: When updating these operators, also update
+ // pkg/sass-parser/lib/src/expression/binary-operation.ts.
+
/// The Microsoft equals operator, `=`.
singleEquals('single equals', '=', 0),
diff --git a/lib/src/ast/sass/expression/boolean.dart b/lib/src/ast/sass/expression/boolean.dart
index 23474a3f6..fa79e23a7 100644
--- a/lib/src/ast/sass/expression/boolean.dart
+++ b/lib/src/ast/sass/expression/boolean.dart
@@ -10,7 +10,7 @@ import '../expression.dart';
/// A boolean literal, `true` or `false`.
///
/// {@category AST}
-final class BooleanExpression implements Expression {
+final class BooleanExpression extends Expression {
/// The value of this expression.
final bool value;
diff --git a/lib/src/ast/sass/expression/color.dart b/lib/src/ast/sass/expression/color.dart
index e81a7f8b8..9713b5f75 100644
--- a/lib/src/ast/sass/expression/color.dart
+++ b/lib/src/ast/sass/expression/color.dart
@@ -11,7 +11,7 @@ import '../expression.dart';
/// A color literal.
///
/// {@category AST}
-final class ColorExpression implements Expression {
+final class ColorExpression extends Expression {
/// The value of this color.
final SassColor value;
diff --git a/lib/src/ast/sass/expression/function.dart b/lib/src/ast/sass/expression/function.dart
index 64c508c19..0f2fce7eb 100644
--- a/lib/src/ast/sass/expression/function.dart
+++ b/lib/src/ast/sass/expression/function.dart
@@ -17,8 +17,8 @@ import '../reference.dart';
/// interpolation.
///
/// {@category AST}
-final class FunctionExpression
- implements Expression, CallableInvocation, SassReference {
+final class FunctionExpression extends Expression
+ implements CallableInvocation, SassReference {
/// The namespace of the function being invoked, or `null` if it's invoked
/// without a namespace.
final String? namespace;
diff --git a/lib/src/ast/sass/expression/if.dart b/lib/src/ast/sass/expression/if.dart
index 8805d4bff..95e305e47 100644
--- a/lib/src/ast/sass/expression/if.dart
+++ b/lib/src/ast/sass/expression/if.dart
@@ -14,7 +14,7 @@ import '../../../visitor/interface/expression.dart';
/// evaluated.
///
/// {@category AST}
-final class IfExpression implements Expression, CallableInvocation {
+final class IfExpression extends Expression implements CallableInvocation {
/// The declaration of `if()`, as though it were a normal function.
static final declaration = ArgumentDeclaration.parse(
r"@function if($condition, $if-true, $if-false) {");
diff --git a/lib/src/ast/sass/expression/interpolated_function.dart b/lib/src/ast/sass/expression/interpolated_function.dart
index 3c97b0c9f..cd5e2abf2 100644
--- a/lib/src/ast/sass/expression/interpolated_function.dart
+++ b/lib/src/ast/sass/expression/interpolated_function.dart
@@ -15,8 +15,8 @@ import '../interpolation.dart';
/// This is always a plain CSS function.
///
/// {@category AST}
-final class InterpolatedFunctionExpression
- implements Expression, CallableInvocation {
+final class InterpolatedFunctionExpression extends Expression
+ implements CallableInvocation {
/// The name of the function being invoked.
final Interpolation name;
diff --git a/lib/src/ast/sass/expression/list.dart b/lib/src/ast/sass/expression/list.dart
index 5bf768cac..67d26880e 100644
--- a/lib/src/ast/sass/expression/list.dart
+++ b/lib/src/ast/sass/expression/list.dart
@@ -13,7 +13,7 @@ import 'unary_operation.dart';
/// A list literal.
///
/// {@category AST}
-final class ListExpression implements Expression {
+final class ListExpression extends Expression {
/// The elements of this list.
final List contents;
diff --git a/lib/src/ast/sass/expression/map.dart b/lib/src/ast/sass/expression/map.dart
index 9bc234780..9bbd540f2 100644
--- a/lib/src/ast/sass/expression/map.dart
+++ b/lib/src/ast/sass/expression/map.dart
@@ -10,7 +10,7 @@ import '../expression.dart';
/// A map literal.
///
/// {@category AST}
-final class MapExpression implements Expression {
+final class MapExpression extends Expression {
/// The pairs in this map.
///
/// This is a list of pairs rather than a map because a map may have two keys
diff --git a/lib/src/ast/sass/expression/null.dart b/lib/src/ast/sass/expression/null.dart
index 4155c00b0..c1f0b583e 100644
--- a/lib/src/ast/sass/expression/null.dart
+++ b/lib/src/ast/sass/expression/null.dart
@@ -10,7 +10,7 @@ import '../expression.dart';
/// A null literal.
///
/// {@category AST}
-final class NullExpression implements Expression {
+final class NullExpression extends Expression {
final FileSpan span;
NullExpression(this.span);
diff --git a/lib/src/ast/sass/expression/number.dart b/lib/src/ast/sass/expression/number.dart
index 7eb2b6fd9..2078e7148 100644
--- a/lib/src/ast/sass/expression/number.dart
+++ b/lib/src/ast/sass/expression/number.dart
@@ -11,7 +11,7 @@ import '../expression.dart';
/// A number literal.
///
/// {@category AST}
-final class NumberExpression implements Expression {
+final class NumberExpression extends Expression {
/// The numeric value.
final double value;
diff --git a/lib/src/ast/sass/expression/parenthesized.dart b/lib/src/ast/sass/expression/parenthesized.dart
index 3788645e3..3459756a5 100644
--- a/lib/src/ast/sass/expression/parenthesized.dart
+++ b/lib/src/ast/sass/expression/parenthesized.dart
@@ -10,7 +10,7 @@ import '../expression.dart';
/// An expression wrapped in parentheses.
///
/// {@category AST}
-final class ParenthesizedExpression implements Expression {
+final class ParenthesizedExpression extends Expression {
/// The internal expression.
final Expression expression;
diff --git a/lib/src/ast/sass/expression/selector.dart b/lib/src/ast/sass/expression/selector.dart
index 81356690b..85365d84a 100644
--- a/lib/src/ast/sass/expression/selector.dart
+++ b/lib/src/ast/sass/expression/selector.dart
@@ -10,7 +10,7 @@ import '../expression.dart';
/// A parent selector reference, `&`.
///
/// {@category AST}
-final class SelectorExpression implements Expression {
+final class SelectorExpression extends Expression {
final FileSpan span;
SelectorExpression(this.span);
diff --git a/lib/src/ast/sass/expression/string.dart b/lib/src/ast/sass/expression/string.dart
index a8539146a..cddb9e848 100644
--- a/lib/src/ast/sass/expression/string.dart
+++ b/lib/src/ast/sass/expression/string.dart
@@ -16,11 +16,12 @@ import '../interpolation.dart';
/// A string literal.
///
/// {@category AST}
-final class StringExpression implements Expression {
+final class StringExpression extends Expression {
/// Interpolation that, when evaluated, produces the contents of this string.
///
- /// Unlike [asInterpolation], escapes are resolved and quotes are not
- /// included.
+ /// If this is a quoted string, escapes are resolved and quotes are not
+ /// included in this text (unlike [asInterpolation]). If it's an unquoted
+ /// string, escapes are *not* resolved.
final Interpolation text;
/// Whether `this` has quotes.
diff --git a/lib/src/ast/sass/expression/supports.dart b/lib/src/ast/sass/expression/supports.dart
index d5de09a75..142a72e74 100644
--- a/lib/src/ast/sass/expression/supports.dart
+++ b/lib/src/ast/sass/expression/supports.dart
@@ -14,7 +14,7 @@ import '../supports_condition.dart';
/// doesn't include the function name wrapping the condition.
///
/// {@category AST}
-final class SupportsExpression implements Expression {
+final class SupportsExpression extends Expression {
/// The condition itself.
final SupportsCondition condition;
diff --git a/lib/src/ast/sass/expression/unary_operation.dart b/lib/src/ast/sass/expression/unary_operation.dart
index 18e5f0c27..913d1ef9e 100644
--- a/lib/src/ast/sass/expression/unary_operation.dart
+++ b/lib/src/ast/sass/expression/unary_operation.dart
@@ -13,7 +13,7 @@ import 'list.dart';
/// A unary operator, as in `+$var` or `not fn()`.
///
/// {@category AST}
-final class UnaryOperationExpression implements Expression {
+final class UnaryOperationExpression extends Expression {
/// The operator being invoked.
final UnaryOperator operator;
diff --git a/lib/src/ast/sass/expression/value.dart b/lib/src/ast/sass/expression/value.dart
index 75b01212e..4d2436555 100644
--- a/lib/src/ast/sass/expression/value.dart
+++ b/lib/src/ast/sass/expression/value.dart
@@ -14,7 +14,7 @@ import '../expression.dart';
/// constructed dynamically, as for the `call()` function.
///
/// {@category AST}
-final class ValueExpression implements Expression {
+final class ValueExpression extends Expression {
/// The embedded value.
final Value value;
diff --git a/lib/src/ast/sass/expression/variable.dart b/lib/src/ast/sass/expression/variable.dart
index 7a839d867..689e72930 100644
--- a/lib/src/ast/sass/expression/variable.dart
+++ b/lib/src/ast/sass/expression/variable.dart
@@ -12,7 +12,7 @@ import '../reference.dart';
/// A Sass variable.
///
/// {@category AST}
-final class VariableExpression implements Expression, SassReference {
+final class VariableExpression extends Expression implements SassReference {
/// The namespace of the variable being referenced, or `null` if it's
/// referenced without a namespace.
final String? namespace;
diff --git a/lib/src/ast/sass/statement.dart b/lib/src/ast/sass/statement.dart
index 123cf3362..d2c31bf13 100644
--- a/lib/src/ast/sass/statement.dart
+++ b/lib/src/ast/sass/statement.dart
@@ -5,10 +5,13 @@
import '../../visitor/interface/statement.dart';
import 'node.dart';
+// Note: despite not defining any methods here, this has to be a concrete class
+// so we can expose its accept() function to the JS parser.
+
/// A statement in a Sass syntax tree.
///
/// {@category AST}
-abstract interface class Statement implements SassNode {
+abstract class Statement implements SassNode {
/// Calls the appropriate visit method on [visitor].
T accept(StatementVisitor visitor);
}
diff --git a/lib/src/ast/sass/statement/content_rule.dart b/lib/src/ast/sass/statement/content_rule.dart
index a05066ef0..8d451207b 100644
--- a/lib/src/ast/sass/statement/content_rule.dart
+++ b/lib/src/ast/sass/statement/content_rule.dart
@@ -14,7 +14,7 @@ import '../statement.dart';
/// caller.
///
/// {@category AST}
-final class ContentRule implements Statement {
+final class ContentRule extends Statement {
/// The arguments pass to this `@content` rule.
///
/// This will be an empty invocation if `@content` has no arguments.
diff --git a/lib/src/ast/sass/statement/debug_rule.dart b/lib/src/ast/sass/statement/debug_rule.dart
index 47c2d452d..db9d0aacb 100644
--- a/lib/src/ast/sass/statement/debug_rule.dart
+++ b/lib/src/ast/sass/statement/debug_rule.dart
@@ -13,7 +13,7 @@ import '../statement.dart';
/// This prints a Sass value for debugging purposes.
///
/// {@category AST}
-final class DebugRule implements Statement {
+final class DebugRule extends Statement {
/// The expression to print.
final Expression expression;
diff --git a/lib/src/ast/sass/statement/error_rule.dart b/lib/src/ast/sass/statement/error_rule.dart
index 977567cbd..756aa32cd 100644
--- a/lib/src/ast/sass/statement/error_rule.dart
+++ b/lib/src/ast/sass/statement/error_rule.dart
@@ -13,7 +13,7 @@ import '../statement.dart';
/// This emits an error and stops execution.
///
/// {@category AST}
-final class ErrorRule implements Statement {
+final class ErrorRule extends Statement {
/// The expression to evaluate for the error message.
final Expression expression;
diff --git a/lib/src/ast/sass/statement/extend_rule.dart b/lib/src/ast/sass/statement/extend_rule.dart
index 8aa4e4e33..8faa69356 100644
--- a/lib/src/ast/sass/statement/extend_rule.dart
+++ b/lib/src/ast/sass/statement/extend_rule.dart
@@ -13,7 +13,7 @@ import '../statement.dart';
/// This gives one selector all the styling of another.
///
/// {@category AST}
-final class ExtendRule implements Statement {
+final class ExtendRule extends Statement {
/// The interpolation for the selector that will be extended.
final Interpolation selector;
diff --git a/lib/src/ast/sass/statement/forward_rule.dart b/lib/src/ast/sass/statement/forward_rule.dart
index eea2a226d..7a680e935 100644
--- a/lib/src/ast/sass/statement/forward_rule.dart
+++ b/lib/src/ast/sass/statement/forward_rule.dart
@@ -15,7 +15,7 @@ import '../statement.dart';
/// A `@forward` rule.
///
/// {@category AST}
-final class ForwardRule implements Statement, SassDependency {
+final class ForwardRule extends Statement implements SassDependency {
/// The URI of the module to forward.
///
/// If this is relative, it's relative to the containing file.
diff --git a/lib/src/ast/sass/statement/if_rule.dart b/lib/src/ast/sass/statement/if_rule.dart
index 0e611df12..22b5a03c3 100644
--- a/lib/src/ast/sass/statement/if_rule.dart
+++ b/lib/src/ast/sass/statement/if_rule.dart
@@ -20,7 +20,7 @@ import 'variable_declaration.dart';
/// This conditionally executes a block of code.
///
/// {@category AST}
-final class IfRule implements Statement {
+final class IfRule extends Statement {
/// The `@if` and `@else if` clauses.
///
/// The first clause whose expression evaluates to `true` will have its
diff --git a/lib/src/ast/sass/statement/import_rule.dart b/lib/src/ast/sass/statement/import_rule.dart
index 425c3ac42..8eefd4af4 100644
--- a/lib/src/ast/sass/statement/import_rule.dart
+++ b/lib/src/ast/sass/statement/import_rule.dart
@@ -11,7 +11,7 @@ import '../statement.dart';
/// An `@import` rule.
///
/// {@category AST}
-final class ImportRule implements Statement {
+final class ImportRule extends Statement {
/// The imports imported by this statement.
final List imports;
diff --git a/lib/src/ast/sass/statement/include_rule.dart b/lib/src/ast/sass/statement/include_rule.dart
index 940716cac..98151665e 100644
--- a/lib/src/ast/sass/statement/include_rule.dart
+++ b/lib/src/ast/sass/statement/include_rule.dart
@@ -15,8 +15,8 @@ import 'content_block.dart';
/// A mixin invocation.
///
/// {@category AST}
-final class IncludeRule
- implements Statement, CallableInvocation, SassReference {
+final class IncludeRule extends Statement
+ implements CallableInvocation, SassReference {
/// The namespace of the mixin being invoked, or `null` if it's invoked
/// without a namespace.
final String? namespace;
diff --git a/lib/src/ast/sass/statement/loud_comment.dart b/lib/src/ast/sass/statement/loud_comment.dart
index 0c48e09fc..84b557558 100644
--- a/lib/src/ast/sass/statement/loud_comment.dart
+++ b/lib/src/ast/sass/statement/loud_comment.dart
@@ -11,7 +11,7 @@ import '../statement.dart';
/// A loud CSS-style comment.
///
/// {@category AST}
-final class LoudComment implements Statement {
+final class LoudComment extends Statement {
/// The interpolated text of this comment, including comment characters.
final Interpolation text;
diff --git a/lib/src/ast/sass/statement/parent.dart b/lib/src/ast/sass/statement/parent.dart
index 21293019d..4067a2a18 100644
--- a/lib/src/ast/sass/statement/parent.dart
+++ b/lib/src/ast/sass/statement/parent.dart
@@ -18,7 +18,7 @@ import 'variable_declaration.dart';
///
/// {@category AST}
abstract base class ParentStatement?>
- implements Statement {
+ extends Statement {
/// The child statements of this statement.
final T children;
diff --git a/lib/src/ast/sass/statement/return_rule.dart b/lib/src/ast/sass/statement/return_rule.dart
index dc1efc65c..53a69af5a 100644
--- a/lib/src/ast/sass/statement/return_rule.dart
+++ b/lib/src/ast/sass/statement/return_rule.dart
@@ -13,7 +13,7 @@ import '../statement.dart';
/// This exits from the current function body with a return value.
///
/// {@category AST}
-final class ReturnRule implements Statement {
+final class ReturnRule extends Statement {
/// The value to return from this function.
final Expression expression;
diff --git a/lib/src/ast/sass/statement/silent_comment.dart b/lib/src/ast/sass/statement/silent_comment.dart
index 384cd09fb..0d799e139 100644
--- a/lib/src/ast/sass/statement/silent_comment.dart
+++ b/lib/src/ast/sass/statement/silent_comment.dart
@@ -11,7 +11,7 @@ import '../statement.dart';
/// A silent Sass-style comment.
///
/// {@category AST}
-final class SilentComment implements Statement {
+final class SilentComment extends Statement {
/// The text of this comment, including comment characters.
final String text;
diff --git a/lib/src/ast/sass/statement/use_rule.dart b/lib/src/ast/sass/statement/use_rule.dart
index 244613abc..77aa81eda 100644
--- a/lib/src/ast/sass/statement/use_rule.dart
+++ b/lib/src/ast/sass/statement/use_rule.dart
@@ -18,7 +18,7 @@ import '../statement.dart';
/// A `@use` rule.
///
/// {@category AST}
-final class UseRule implements Statement, SassDependency {
+final class UseRule extends Statement implements SassDependency {
/// The URI of the module to use.
///
/// If this is relative, it's relative to the containing file.
diff --git a/lib/src/ast/sass/statement/variable_declaration.dart b/lib/src/ast/sass/statement/variable_declaration.dart
index 235a41648..f94619e99 100644
--- a/lib/src/ast/sass/statement/variable_declaration.dart
+++ b/lib/src/ast/sass/statement/variable_declaration.dart
@@ -21,7 +21,7 @@ import 'silent_comment.dart';
/// This defines or sets a variable.
///
/// {@category AST}
-final class VariableDeclaration implements Statement, SassDeclaration {
+final class VariableDeclaration extends Statement implements SassDeclaration {
/// The namespace of the variable being set, or `null` if it's defined or set
/// without a namespace.
final String? namespace;
diff --git a/lib/src/ast/sass/statement/warn_rule.dart b/lib/src/ast/sass/statement/warn_rule.dart
index 026f4ca34..ebc175084 100644
--- a/lib/src/ast/sass/statement/warn_rule.dart
+++ b/lib/src/ast/sass/statement/warn_rule.dart
@@ -13,7 +13,7 @@ import '../statement.dart';
/// This prints a Sass value—usually a string—to warn the user of something.
///
/// {@category AST}
-final class WarnRule implements Statement {
+final class WarnRule extends Statement {
/// The expression to print.
final Expression expression;
diff --git a/lib/src/js.dart b/lib/src/js.dart
index dc0384bc4..0dd47686f 100644
--- a/lib/src/js.dart
+++ b/lib/src/js.dart
@@ -14,6 +14,7 @@ import 'js/legacy.dart';
import 'js/legacy/types.dart';
import 'js/legacy/value.dart';
import 'js/logger.dart';
+import 'js/parser.dart';
import 'js/source_span.dart';
import 'js/utils.dart';
import 'js/value.dart';
@@ -58,6 +59,7 @@ void main() {
exports.NodePackageImporter = nodePackageImporterClass;
exports.deprecations = jsify(deprecations);
exports.Version = versionClass;
+ exports.loadParserExports_ = allowInterop(loadParserExports);
exports.info =
"dart-sass\t${const String.fromEnvironment('version')}\t(Sass Compiler)\t"
diff --git a/lib/src/js/exports.dart b/lib/src/js/exports.dart
index e106ad610..225f5fcf7 100644
--- a/lib/src/js/exports.dart
+++ b/lib/src/js/exports.dart
@@ -55,6 +55,9 @@ class Exports {
external set NULL(value.Value sassNull);
external set TRUE(value.SassBoolean sassTrue);
external set FALSE(value.SassBoolean sassFalse);
+
+ // `sass-parser` APIs
+ external set loadParserExports_(Function function);
}
@JS()
diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart
new file mode 100644
index 000000000..92359db57
--- /dev/null
+++ b/lib/src/js/parser.dart
@@ -0,0 +1,94 @@
+// 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.
+
+// ignore_for_file: non_constant_identifier_names
+// See dart-lang/sdk#47374
+
+import 'package:js/js.dart';
+import 'package:path/path.dart' as p;
+import 'package:source_span/source_span.dart';
+
+import '../ast/sass.dart';
+import '../logger.dart';
+import '../logger/js_to_dart.dart';
+import '../syntax.dart';
+import '../util/nullable.dart';
+import '../util/span.dart';
+import '../visitor/interface/expression.dart';
+import '../visitor/interface/statement.dart';
+import 'logger.dart';
+import 'reflection.dart';
+import 'visitor/expression.dart';
+import 'visitor/statement.dart';
+
+@JS()
+@anonymous
+class ParserExports {
+ external factory ParserExports(
+ {required Function parse,
+ required Function createExpressionVisitor,
+ required Function createStatementVisitor});
+
+ external set parse(Function function);
+ external set createStatementVisitor(Function function);
+ external set createExpressionVisitor(Function function);
+}
+
+/// Loads and returns all the exports needed for the `sass-parser` package.
+ParserExports loadParserExports() {
+ _updateAstPrototypes();
+ return ParserExports(
+ parse: allowInterop(_parse),
+ createExpressionVisitor: allowInterop(
+ (JSExpressionVisitorObject inner) => JSExpressionVisitor(inner)),
+ createStatementVisitor: allowInterop(
+ (JSStatementVisitorObject inner) => JSStatementVisitor(inner)));
+}
+
+/// Modifies the prototypes of the Sass AST classes to provide access to JS.
+///
+/// This API is not intended to be used directly by end users and is subject to
+/// breaking changes without notice. Instead, it's wrapped by the `sass-parser`
+/// package which exposes a PostCSS-style API.
+void _updateAstPrototypes() {
+ // We don't need explicit getters for field names, because dart2js preserves
+ // them as-is, so we actually need to expose very little to JS manually.
+ var file = SourceFile.fromString('');
+ getJSClass(file).defineMethod('getText',
+ (SourceFile self, int start, [int? end]) => self.getText(start, end));
+ var interpolation = Interpolation(const [], bogusSpan);
+ getJSClass(interpolation)
+ .defineGetter('asPlain', (Interpolation self) => self.asPlain);
+ getJSClass(ExtendRule(interpolation, bogusSpan)).superclass.defineMethod(
+ 'accept',
+ (Statement self, StatementVisitor visitor) =>
+ self.accept(visitor));
+ var string = StringExpression(interpolation);
+ getJSClass(string).superclass.defineMethod(
+ 'accept',
+ (Expression self, ExpressionVisitor visitor) =>
+ self.accept(visitor));
+
+ for (var node in [
+ string,
+ BinaryOperationExpression(BinaryOperator.plus, string, string),
+ SupportsExpression(SupportsAnything(interpolation, bogusSpan)),
+ LoudComment(interpolation)
+ ]) {
+ getJSClass(node).defineGetter('span', (SassNode self) => self.span);
+ }
+}
+
+/// A JavaScript-friendly method to parse a stylesheet.
+Stylesheet _parse(String css, String syntax, String? path, JSLogger? logger) =>
+ Stylesheet.parse(
+ css,
+ switch (syntax) {
+ 'scss' => Syntax.scss,
+ 'sass' => Syntax.sass,
+ 'css' => Syntax.css,
+ _ => throw UnsupportedError('Unknown syntax "$syntax"')
+ },
+ url: path.andThen(p.toUri),
+ logger: JSToDartLogger(logger, Logger.stderr()));
diff --git a/lib/src/js/visitor/expression.dart b/lib/src/js/visitor/expression.dart
new file mode 100644
index 000000000..88fa684de
--- /dev/null
+++ b/lib/src/js/visitor/expression.dart
@@ -0,0 +1,74 @@
+// 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:js/js.dart';
+
+import '../../ast/sass.dart';
+import '../../visitor/interface/expression.dart';
+
+/// A wrapper around a JS object that implements the [ExpressionVisitor] methods.
+class JSExpressionVisitor implements ExpressionVisitor {
+ final JSExpressionVisitorObject _inner;
+
+ JSExpressionVisitor(this._inner);
+
+ Object? visitBinaryOperationExpression(BinaryOperationExpression node) =>
+ _inner.visitBinaryOperationExpression(node);
+ Object? visitBooleanExpression(BooleanExpression node) =>
+ _inner.visitBooleanExpression(node);
+ Object? visitColorExpression(ColorExpression node) =>
+ _inner.visitColorExpression(node);
+ Object? visitInterpolatedFunctionExpression(
+ InterpolatedFunctionExpression node) =>
+ _inner.visitInterpolatedFunctionExpression(node);
+ Object? visitFunctionExpression(FunctionExpression node) =>
+ _inner.visitFunctionExpression(node);
+ Object? visitIfExpression(IfExpression node) =>
+ _inner.visitIfExpression(node);
+ Object? visitListExpression(ListExpression node) =>
+ _inner.visitListExpression(node);
+ Object? visitMapExpression(MapExpression node) =>
+ _inner.visitMapExpression(node);
+ Object? visitNullExpression(NullExpression node) =>
+ _inner.visitNullExpression(node);
+ Object? visitNumberExpression(NumberExpression node) =>
+ _inner.visitNumberExpression(node);
+ Object? visitParenthesizedExpression(ParenthesizedExpression node) =>
+ _inner.visitParenthesizedExpression(node);
+ Object? visitSelectorExpression(SelectorExpression node) =>
+ _inner.visitSelectorExpression(node);
+ Object? visitStringExpression(StringExpression node) =>
+ _inner.visitStringExpression(node);
+ Object? visitSupportsExpression(SupportsExpression node) =>
+ _inner.visitSupportsExpression(node);
+ Object? visitUnaryOperationExpression(UnaryOperationExpression node) =>
+ _inner.visitUnaryOperationExpression(node);
+ Object? visitValueExpression(ValueExpression node) =>
+ _inner.visitValueExpression(node);
+ Object? visitVariableExpression(VariableExpression node) =>
+ _inner.visitVariableExpression(node);
+}
+
+@JS()
+class JSExpressionVisitorObject {
+ external Object? visitBinaryOperationExpression(
+ BinaryOperationExpression node);
+ external Object? visitBooleanExpression(BooleanExpression node);
+ external Object? visitColorExpression(ColorExpression node);
+ external Object? visitInterpolatedFunctionExpression(
+ InterpolatedFunctionExpression node);
+ external Object? visitFunctionExpression(FunctionExpression node);
+ external Object? visitIfExpression(IfExpression node);
+ external Object? visitListExpression(ListExpression node);
+ external Object? visitMapExpression(MapExpression node);
+ external Object? visitNullExpression(NullExpression node);
+ external Object? visitNumberExpression(NumberExpression node);
+ external Object? visitParenthesizedExpression(ParenthesizedExpression node);
+ external Object? visitSelectorExpression(SelectorExpression node);
+ external Object? visitStringExpression(StringExpression node);
+ external Object? visitSupportsExpression(SupportsExpression node);
+ external Object? visitUnaryOperationExpression(UnaryOperationExpression node);
+ external Object? visitValueExpression(ValueExpression node);
+ external Object? visitVariableExpression(VariableExpression node);
+}
diff --git a/lib/src/js/visitor/statement.dart b/lib/src/js/visitor/statement.dart
new file mode 100644
index 000000000..71ee96945
--- /dev/null
+++ b/lib/src/js/visitor/statement.dart
@@ -0,0 +1,79 @@
+// 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:js/js.dart';
+
+import '../../ast/sass.dart';
+import '../../visitor/interface/statement.dart';
+
+/// A wrapper around a JS object that implements the [StatementVisitor] methods.
+class JSStatementVisitor implements StatementVisitor {
+ final JSStatementVisitorObject _inner;
+
+ JSStatementVisitor(this._inner);
+
+ Object? visitAtRootRule(AtRootRule node) => _inner.visitAtRootRule(node);
+ Object? visitAtRule(AtRule node) => _inner.visitAtRule(node);
+ Object? visitContentBlock(ContentBlock node) =>
+ _inner.visitContentBlock(node);
+ Object? visitContentRule(ContentRule node) => _inner.visitContentRule(node);
+ Object? visitDebugRule(DebugRule node) => _inner.visitDebugRule(node);
+ Object? visitDeclaration(Declaration node) => _inner.visitDeclaration(node);
+ Object? visitEachRule(EachRule node) => _inner.visitEachRule(node);
+ Object? visitErrorRule(ErrorRule node) => _inner.visitErrorRule(node);
+ Object? visitExtendRule(ExtendRule node) => _inner.visitExtendRule(node);
+ Object? visitForRule(ForRule node) => _inner.visitForRule(node);
+ Object? visitForwardRule(ForwardRule node) => _inner.visitForwardRule(node);
+ Object? visitFunctionRule(FunctionRule node) =>
+ _inner.visitFunctionRule(node);
+ Object? visitIfRule(IfRule node) => _inner.visitIfRule(node);
+ Object? visitImportRule(ImportRule node) => _inner.visitImportRule(node);
+ Object? visitIncludeRule(IncludeRule node) => _inner.visitIncludeRule(node);
+ Object? visitLoudComment(LoudComment node) => _inner.visitLoudComment(node);
+ Object? visitMediaRule(MediaRule node) => _inner.visitMediaRule(node);
+ Object? visitMixinRule(MixinRule node) => _inner.visitMixinRule(node);
+ Object? visitReturnRule(ReturnRule node) => _inner.visitReturnRule(node);
+ Object? visitSilentComment(SilentComment node) =>
+ _inner.visitSilentComment(node);
+ Object? visitStyleRule(StyleRule node) => _inner.visitStyleRule(node);
+ Object? visitStylesheet(Stylesheet node) => _inner.visitStylesheet(node);
+ Object? visitSupportsRule(SupportsRule node) =>
+ _inner.visitSupportsRule(node);
+ Object? visitUseRule(UseRule node) => _inner.visitUseRule(node);
+ Object? visitVariableDeclaration(VariableDeclaration node) =>
+ _inner.visitVariableDeclaration(node);
+ Object? visitWarnRule(WarnRule node) => _inner.visitWarnRule(node);
+ Object? visitWhileRule(WhileRule node) => _inner.visitWhileRule(node);
+}
+
+@JS()
+class JSStatementVisitorObject {
+ external Object? visitAtRootRule(AtRootRule node);
+ external Object? visitAtRule(AtRule node);
+ external Object? visitContentBlock(ContentBlock node);
+ external Object? visitContentRule(ContentRule node);
+ external Object? visitDebugRule(DebugRule node);
+ external Object? visitDeclaration(Declaration node);
+ external Object? visitEachRule(EachRule node);
+ external Object? visitErrorRule(ErrorRule node);
+ external Object? visitExtendRule(ExtendRule node);
+ external Object? visitForRule(ForRule node);
+ external Object? visitForwardRule(ForwardRule node);
+ external Object? visitFunctionRule(FunctionRule node);
+ external Object? visitIfRule(IfRule node);
+ external Object? visitImportRule(ImportRule node);
+ external Object? visitIncludeRule(IncludeRule node);
+ external Object? visitLoudComment(LoudComment node);
+ external Object? visitMediaRule(MediaRule node);
+ external Object? visitMixinRule(MixinRule node);
+ external Object? visitReturnRule(ReturnRule node);
+ external Object? visitSilentComment(SilentComment node);
+ external Object? visitStyleRule(StyleRule node);
+ external Object? visitStylesheet(Stylesheet node);
+ external Object? visitSupportsRule(SupportsRule node);
+ external Object? visitUseRule(UseRule node);
+ external Object? visitVariableDeclaration(VariableDeclaration node);
+ external Object? visitWarnRule(WarnRule node);
+ external Object? visitWhileRule(WhileRule node);
+}
diff --git a/pkg/sass-parser/.eslintignore b/pkg/sass-parser/.eslintignore
new file mode 100644
index 000000000..dca1b9aa7
--- /dev/null
+++ b/pkg/sass-parser/.eslintignore
@@ -0,0 +1,2 @@
+dist/
+**/*.js
diff --git a/pkg/sass-parser/.eslintrc b/pkg/sass-parser/.eslintrc
new file mode 100644
index 000000000..3b5b91232
--- /dev/null
+++ b/pkg/sass-parser/.eslintrc
@@ -0,0 +1,14 @@
+{
+ "extends": "./node_modules/gts/",
+ "rules": {
+ "@typescript-eslint/explicit-function-return-type": [
+ "error",
+ {"allowExpressions": true}
+ ],
+ "func-style": ["error", "declaration"],
+ "prefer-const": ["error", {"destructuring": "all"}],
+ // It would be nice to sort import declaration order as well, but that's not
+ // autofixable and it's not worth the effort of handling manually.
+ "sort-imports": ["error", {"ignoreDeclarationSort": true}],
+ }
+}
diff --git a/pkg/sass-parser/.prettierrc.js b/pkg/sass-parser/.prettierrc.js
new file mode 100644
index 000000000..c5166c2ae
--- /dev/null
+++ b/pkg/sass-parser/.prettierrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ ...require('gts/.prettierrc.json'),
+};
diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md
new file mode 100644
index 000000000..924d79f86
--- /dev/null
+++ b/pkg/sass-parser/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.2.0
+
+* Initial unstable release.
diff --git a/pkg/sass-parser/README.md b/pkg/sass-parser/README.md
new file mode 100644
index 000000000..db6e98f7e
--- /dev/null
+++ b/pkg/sass-parser/README.md
@@ -0,0 +1,248 @@
+A [PostCSS]-compatible CSS and [Sass] parser with full expression support.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+[PostCSS]: https://postcss.org/
+[Sass]: https://sass-lang.com/
+
+**Warning:** `sass-parser` is still in active development, and is not yet
+suitable for production use. At time of writing it only supports a small subset
+of CSS and Sass syntax. In addition, it does not yet support parsing raws
+(metadata about the original formatting of the document), which makes it
+unsuitable for certain source-to-source transformations.
+
+* [Using `sass-parser`](#using-sass-parser)
+* [Why `sass-parser`?](#why-sass-parser)
+* [API Documentation](#api-documentation)
+* [PostCSS Compatibility](#postcss-compatibility)
+ * [Statement API](#statement-api)
+ * [Expression API](#expression-api)
+ * [Constructing New Nodes](#constructing-new-nodes)
+
+## Using `sass-parser`
+
+1. Install the `sass-parser` package from the npm repository:
+
+ ```sh
+ npm install sass-parser
+ ```
+
+2. Use the `scss`, `sass`, or `css` [`Syntax` objects] exports to parse a file.
+
+ ```js
+ const sassParser = require('sass-parser');
+
+ const root = sassParser.scss.parse(`
+ @use 'colors';
+
+ body {
+ color: colors.$midnight-blue;
+ }
+ `);
+ ```
+
+3. Use the standard [PostCSS API] to inspect and edit the stylesheet:
+
+ ```js
+ const styleRule = root.nodes[1];
+ styleRule.selector = '.container';
+
+ console.log(root.toString());
+ // @use 'colors';
+ //
+ // .container {
+ // color: colors.$midnight-blue;
+ // }
+ ```
+
+4. Use new PostCSS-style APIs to inspect and edit expressions and Sass-specific
+ rules:
+
+ ```js
+ root.nodes[0].namespace = 'c';
+ const variable = styleRule.nodes[0].valueExpression;
+ variable.namespace = 'c';
+
+ console.log(root.toString());
+ // @use 'colors' as c;
+ //
+ // .container {
+ // color: c.$midnight-blue;
+ // }
+ ```
+
+[`Syntax` objects]: https://postcss.org/api/#syntax
+[PostCSS API]: https://postcss.org/api/
+
+## Why `sass-parser`?
+
+We decided to expose [Dart Sass]'s parser as a JS API because we saw two needs
+that were going unmet.
+
+[Dart Sass]: https://sass-lang.com/dart-sass
+
+First, there was no fully-compatible Sass parser. Although a [`postcss-scss`]
+plugin did exist, its author [requested we create this package] to fix
+compatibility issues, support [the indented syntax], and provide first-class
+support for Sass-specific rules without needing them to be manually parsed by
+each user.
+
+[`postcss-scss`]: https://www.npmjs.com/package/postcss-scss
+[requested we create this package]: https://github.com/sass/dart-sass/issues/88#issuecomment-270069138
+[the indented syntax]: https://sass-lang.com/documentation/syntax/#the-indented-syntax
+
+Moreover, there was no robust solution for parsing the expressions that are used
+as the values of CSS declarations (as well as Sass variable values). This was
+true even for plain CSS, and doubly problematic for Sass's particularly complex
+expression syntax. The [`postcss-value-parser`] package hasn't been updated
+since 2021, the [`postcss-values-parser`] since January 2022, and even the
+excellent [`@csstools/css-parser-algorithms`] had limited PostCSS integration
+and no intention of ever supporting Sass.
+
+[`postcss-value-parser`]: https://www.npmjs.com/package/postcss-value-parser
+[`postcss-values-parser`]: https://www.npmjs.com/package/postcss-values-parser
+[`@csstools/css-parser-algorithms`]: https://www.npmjs.com/package/@csstools/css-parser-algorithms
+
+The `sass-parser` package intends to solve these problems by providing a parser
+that's battle-tested by millions of Sass users and flexible enough to support
+use-cases that don't involve Sass at all. We intend it to be usable as a drop-in
+replacement for the standard PostCSS parser, and for the new expression-level
+APIs to feel highly familiar to anyone used to PostCSS.
+
+## API Documentation
+
+The source code is fully documented using [TypeDoc]. Hosted, formatted
+documentation will be coming soon.
+
+[TypeDoc]: https://typedoc.org
+
+## PostCSS Compatibility
+
+[PostCSS] is the most popular and long-lived CSS post-processing framework in
+the world, and this package aims to be fully compatible with its API. Where we
+add new features, we do so in a way that's as similar to PostCSS as possible,
+re-using its types and even implementation wherever possible.
+
+### Statement API
+
+All statement-level [AST] nodes produced by `sass-parser`—style rules, at-rules,
+declarations, statement-level comments, and the root node—extend the
+corresponding PostCSS node types ([`Rule`], [`AtRule`], [`Declaration`],
+[`Comment`], and [`Root`]). However, `sass-parser` has multiple subclasses for
+many of its PostCSS superclasses. For example, `sassParser.PropertyDeclaration`
+extends `postcss.Declaration`, but so does `sassParser.VariableDeclaration`. The
+different `sass-parser` node types may be distinguished using the
+`sassParser.Node.sassType` field.
+
+[AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree
+[`Rule`]: https://postcss.org/api/#rule
+[`AtRule`]: https://postcss.org/api/#atrule
+[`Declaration`]: https://postcss.org/api/#declaration
+[`Comment`]: https://postcss.org/api/#comment
+[`Root`]: https://postcss.org/api/#root
+
+In addition to supporting the standard PostCSS properties like
+`Declaration.value` and `Rule.selector`, `sass-parser` provides more detailed
+parsed values. For example, `sassParser.Declaration.valueExpression` provides
+the declaration's value as a fully-parsed syntax tree rather than a string, and
+`sassParser.Rule.selectorInterpolation` provides access to any interpolated
+expressions as in `.prefix-#{$variable} { /*...*/ }`. These parsed values are
+automatically kept up-to-date with the standard PostCSS properties.
+
+### Expression API
+
+The expression-level AST nodes inherit from PostCSS's [`Node`] class but not any
+of its more specific nodes. Nor do expressions support all the PostCSS `Node`
+APIs: unlike statements, expressions that contain other expressions don't always
+contain them as a clearly-ordered list, so methods like `Node.before()` and
+`Node.next` aren't available. Just like with `sass-parser` statements, you can
+distinguish between expressions using the `sassType` field.
+
+[`Node`]: https://postcss.org/api/#node
+
+Just like standard PostCSS nodes, expression nodes can be modified in-place and
+these modifications will be reflected in the CSS output. Each expression type
+has its own specific set of properties which can be read about in the expression
+documentation.
+
+### Constructing New Nodes
+
+All Sass nodes, whether expressions, statements, or miscellaneous nodes like
+`Interpolation`s, can be constructed as standard JavaScript objects:
+
+```js
+const sassParser = require('sass-parser');
+
+const root = new sassParser.Root();
+root.append(new sassParser.Declaration({
+ prop: 'content',
+ valueExpression: new sassParser.StringExpression({
+ quotes: true,
+ text: new sassParser.Interpolation({
+ nodes: ["hello, world!"],
+ }),
+ }),
+}));
+```
+
+However, the same shorthands can be used as when adding new nodes in standard
+PostCSS, as well as a few new ones. Anything that takes an `Interpolation` can
+be passed a string instead to represent plain text with no Sass expressions:
+
+```js
+const sassParser = require('sass-parser');
+
+const root = new sassParser.Root();
+root.append(new sassParser.Declaration({
+ prop: 'content',
+ valueExpression: new sassParser.StringExpression({
+ quotes: true,
+ text: "hello, world!",
+ }),
+}));
+```
+
+Because the mandatory properties for all node types are unambiguous, you can
+leave out the `new ...()` call and just pass the properties directly:
+
+```js
+const sassParser = require('sass-parser');
+
+const root = new sassParser.Root();
+root.append({
+ prop: 'content',
+ valueExpression: {quotes: true, text: "hello, world!"},
+});
+```
+
+You can even pass a string in place of a statement and PostCSS will parse it for
+you! **Warning:** This currently uses the standard PostCSS parser, not the Sass
+parser, and as such it does not support Sass-specific constructs.
+
+```js
+const sassParser = require('sass-parser');
+
+const root = new sassParser.Root();
+root.append('content: "hello, world!"');
+```
diff --git a/pkg/sass-parser/jest.config.ts b/pkg/sass-parser/jest.config.ts
new file mode 100644
index 000000000..bdf7ad067
--- /dev/null
+++ b/pkg/sass-parser/jest.config.ts
@@ -0,0 +1,8 @@
+const config = {
+ preset: 'ts-jest',
+ roots: ['lib'],
+ testEnvironment: 'node',
+ setupFilesAfterEnv: ['jest-extended/all', '/test/setup.ts'],
+};
+
+export default config;
diff --git a/pkg/sass-parser/lib/.npmignore b/pkg/sass-parser/lib/.npmignore
new file mode 100644
index 000000000..b896f526a
--- /dev/null
+++ b/pkg/sass-parser/lib/.npmignore
@@ -0,0 +1 @@
+*.test.ts
diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts
new file mode 100644
index 000000000..7201833d7
--- /dev/null
+++ b/pkg/sass-parser/lib/index.ts
@@ -0,0 +1,102 @@
+// 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 * as sassApi from 'sass';
+
+import {Root} from './src/statement/root';
+import * as sassInternal from './src/sass-internal';
+import {Stringifier} from './src/stringifier';
+
+export {AnyNode, Node, NodeProps, NodeType} from './src/node';
+export {
+ AnyExpression,
+ Expression,
+ ExpressionProps,
+ ExpressionType,
+} from './src/expression';
+export {
+ BinaryOperationExpression,
+ BinaryOperationExpressionProps,
+ BinaryOperationExpressionRaws,
+ BinaryOperator,
+} from './src/expression/binary-operation';
+export {
+ StringExpression,
+ StringExpressionProps,
+ StringExpressionRaws,
+} from './src/expression/string';
+export {
+ Interpolation,
+ InterpolationProps,
+ InterpolationRaws,
+ NewNodeForInterpolation,
+} from './src/interpolation';
+export {
+ GenericAtRule,
+ GenericAtRuleProps,
+ GenericAtRuleRaws,
+} from './src/statement/generic-at-rule';
+export {Root, RootProps, RootRaws} from './src/statement/root';
+export {Rule, RuleProps, RuleRaws} from './src/statement/rule';
+export {
+ AnyStatement,
+ AtRule,
+ ChildNode,
+ ChildProps,
+ ContainerProps,
+ NewNode,
+ Statement,
+ StatementType,
+ StatementWithChildren,
+} from './src/statement';
+
+/** Options that can be passed to the Sass parsers to control their behavior. */
+export interface SassParserOptions
+ extends Pick {
+ /** The logger that's used to log messages encountered during parsing. */
+ logger?: sassApi.Logger;
+}
+
+/** A PostCSS syntax for parsing a particular Sass syntax. */
+export interface Syntax extends postcss.Syntax {
+ parse(css: {toString(): string} | string, opts?: SassParserOptions): Root;
+ stringify: postcss.Stringifier;
+}
+
+/** The internal implementation of the syntax. */
+class _Syntax implements Syntax {
+ /** The syntax with which to parse stylesheets. */
+ readonly #syntax: sassInternal.Syntax;
+
+ constructor(syntax: sassInternal.Syntax) {
+ this.#syntax = syntax;
+ }
+
+ parse(css: {toString(): string} | string, opts?: SassParserOptions): Root {
+ if (opts?.map) {
+ // We might be able to support this as a layer on top of source spans, but
+ // is it worth the effort?
+ throw "sass-parser doesn't currently support consuming source maps.";
+ }
+
+ return new Root(
+ undefined,
+ sassInternal.parse(css.toString(), this.#syntax, opts?.from, opts?.logger)
+ );
+ }
+
+ stringify(node: postcss.AnyNode, builder: postcss.Builder): void {
+ new Stringifier(builder).stringify(node, true);
+ }
+}
+
+/** A PostCSS syntax for parsing SCSS. */
+export const scss: Syntax = new _Syntax('scss');
+
+/** A PostCSS syntax for parsing Sass's indented syntax. */
+export const sass: Syntax = new _Syntax('sass');
+
+/** A PostCSS syntax for parsing plain CSS. */
+export const css: Syntax = new _Syntax('css');
diff --git a/pkg/sass-parser/lib/src/__snapshots__/interpolation.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/interpolation.test.ts.snap
new file mode 100644
index 000000000..4f8fa453c
--- /dev/null
+++ b/pkg/sass-parser/lib/src/__snapshots__/interpolation.test.ts.snap
@@ -0,0 +1,21 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`an interpolation toJSON 1`] = `
+{
+ "inputs": [
+ {
+ "css": "@foo#{bar}baz",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "nodes": [
+ "foo",
+ ,
+ "baz",
+ ],
+ "raws": {},
+ "sassType": "interpolation",
+ "source": <1:2-1:14 in 0>,
+}
+`;
diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/binary-operation.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/binary-operation.test.ts.snap
new file mode 100644
index 000000000..ea2511ded
--- /dev/null
+++ b/pkg/sass-parser/lib/src/expression/__snapshots__/binary-operation.test.ts.snap
@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`a binary operation toJSON 1`] = `
+{
+ "inputs": [
+ {
+ "css": "@#{foo + bar}",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "left": ,
+ "operator": "+",
+ "raws": {},
+ "right": ,
+ "sassType": "binary-operation",
+ "source": <1:4-1:13 in 0>,
+}
+`;
diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/string.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/string.test.ts.snap
new file mode 100644
index 000000000..621190d86
--- /dev/null
+++ b/pkg/sass-parser/lib/src/expression/__snapshots__/string.test.ts.snap
@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`a string expression toJSON 1`] = `
+{
+ "inputs": [
+ {
+ "css": "@#{"foo"}",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "quotes": true,
+ "raws": {},
+ "sassType": "string",
+ "source": <1:4-1:9 in 0>,
+ "text": ,
+}
+`;
diff --git a/pkg/sass-parser/lib/src/expression/binary-operation.test.ts b/pkg/sass-parser/lib/src/expression/binary-operation.test.ts
new file mode 100644
index 000000000..c03cd6c9c
--- /dev/null
+++ b/pkg/sass-parser/lib/src/expression/binary-operation.test.ts
@@ -0,0 +1,201 @@
+// 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 {BinaryOperationExpression, StringExpression} from '../..';
+import * as utils from '../../../test/utils';
+
+describe('a binary operation', () => {
+ let node: BinaryOperationExpression;
+ function describeNode(
+ description: string,
+ create: () => BinaryOperationExpression
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has sassType binary-operation', () =>
+ expect(node.sassType).toBe('binary-operation'));
+
+ it('has an operator', () => expect(node.operator).toBe('+'));
+
+ it('has a left node', () =>
+ expect(node).toHaveStringExpression('left', 'foo'));
+
+ it('has a right node', () =>
+ expect(node).toHaveStringExpression('right', 'bar'));
+ });
+ }
+
+ describeNode('parsed', () => utils.parseExpression('foo + bar'));
+
+ describeNode(
+ 'constructed manually',
+ () =>
+ new BinaryOperationExpression({
+ operator: '+',
+ left: {text: 'foo'},
+ right: {text: 'bar'},
+ })
+ );
+
+ describeNode('constructed from ExpressionProps', () =>
+ utils.fromExpressionProps({
+ operator: '+',
+ left: {text: 'foo'},
+ right: {text: 'bar'},
+ })
+ );
+
+ describe('assigned new', () => {
+ beforeEach(() => void (node = utils.parseExpression('foo + bar')));
+
+ it('operator', () => {
+ node.operator = '*';
+ expect(node.operator).toBe('*');
+ });
+
+ describe('left', () => {
+ it("removes the old left's parent", () => {
+ const oldLeft = node.left;
+ node.left = {text: 'zip'};
+ expect(oldLeft.parent).toBeUndefined();
+ });
+
+ it('assigns left explicitly', () => {
+ const left = new StringExpression({text: 'zip'});
+ node.left = left;
+ expect(node.left).toBe(left);
+ expect(node).toHaveStringExpression('left', 'zip');
+ });
+
+ it('assigns left as ExpressionProps', () => {
+ node.left = {text: 'zip'};
+ expect(node).toHaveStringExpression('left', 'zip');
+ });
+ });
+
+ describe('right', () => {
+ it("removes the old right's parent", () => {
+ const oldRight = node.right;
+ node.right = {text: 'zip'};
+ expect(oldRight.parent).toBeUndefined();
+ });
+
+ it('assigns right explicitly', () => {
+ const right = new StringExpression({text: 'zip'});
+ node.right = right;
+ expect(node.right).toBe(right);
+ expect(node).toHaveStringExpression('right', 'zip');
+ });
+
+ it('assigns right as ExpressionProps', () => {
+ node.right = {text: 'zip'};
+ expect(node).toHaveStringExpression('right', 'zip');
+ });
+ });
+ });
+
+ describe('stringifies', () => {
+ beforeEach(() => void (node = utils.parseExpression('foo + bar')));
+
+ it('without raws', () => expect(node.toString()).toBe('foo + bar'));
+
+ it('with beforeOperator', () => {
+ node.raws.beforeOperator = '/**/';
+ expect(node.toString()).toBe('foo/**/+ bar');
+ });
+
+ it('with afterOperator', () => {
+ node.raws.afterOperator = '/**/';
+ expect(node.toString()).toBe('foo +/**/bar');
+ });
+ });
+
+ describe('clone', () => {
+ let original: BinaryOperationExpression;
+ beforeEach(() => {
+ original = utils.parseExpression('foo + bar');
+ // TODO: remove this once raws are properly parsed
+ original.raws.beforeOperator = ' ';
+ });
+
+ describe('with no overrides', () => {
+ let clone: BinaryOperationExpression;
+ beforeEach(() => void (clone = original.clone()));
+
+ describe('has the same properties:', () => {
+ it('operator', () => expect(clone.operator).toBe('+'));
+
+ it('left', () => expect(clone).toHaveStringExpression('left', 'foo'));
+
+ it('right', () => expect(clone).toHaveStringExpression('right', 'bar'));
+
+ it('raws', () => expect(clone.raws).toEqual({beforeOperator: ' '}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of ['left', 'right', 'raws'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+ });
+
+ describe('overrides', () => {
+ describe('operator', () => {
+ it('defined', () =>
+ expect(original.clone({operator: '*'}).operator).toBe('*'));
+
+ it('undefined', () =>
+ expect(original.clone({operator: undefined}).operator).toBe('+'));
+ });
+
+ describe('left', () => {
+ it('defined', () =>
+ expect(original.clone({left: {text: 'zip'}})).toHaveStringExpression(
+ 'left',
+ 'zip'
+ ));
+
+ it('undefined', () =>
+ expect(original.clone({left: undefined})).toHaveStringExpression(
+ 'left',
+ 'foo'
+ ));
+ });
+
+ describe('right', () => {
+ it('defined', () =>
+ expect(original.clone({right: {text: 'zip'}})).toHaveStringExpression(
+ 'right',
+ 'zip'
+ ));
+
+ it('undefined', () =>
+ expect(original.clone({right: undefined})).toHaveStringExpression(
+ 'right',
+ 'bar'
+ ));
+ });
+
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {afterOperator: ' '}}).raws).toEqual({
+ afterOperator: ' ',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ beforeOperator: ' ',
+ }));
+ });
+ });
+ });
+
+ it('toJSON', () =>
+ expect(utils.parseExpression('foo + bar')).toMatchSnapshot());
+});
diff --git a/pkg/sass-parser/lib/src/expression/binary-operation.ts b/pkg/sass-parser/lib/src/expression/binary-operation.ts
new file mode 100644
index 000000000..12054630c
--- /dev/null
+++ b/pkg/sass-parser/lib/src/expression/binary-operation.ts
@@ -0,0 +1,151 @@
+// 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 {LazySource} from '../lazy-source';
+import type * as sassInternal from '../sass-internal';
+import * as utils from '../utils';
+import {Expression, ExpressionProps} from '.';
+import {convertExpression} from './convert';
+import {fromProps} from './from-props';
+
+/** Different binary operations supported by Sass. */
+export type BinaryOperator =
+ | '='
+ | 'or'
+ | 'and'
+ | '=='
+ | '!='
+ | '>'
+ | '>='
+ | '<'
+ | '<='
+ | '+'
+ | '-'
+ | '*'
+ | '/'
+ | '%';
+
+/**
+ * The initializer properties for {@link BinaryOperationExpression}.
+ *
+ * @category Expression
+ */
+export interface BinaryOperationExpressionProps {
+ operator: BinaryOperator;
+ left: Expression | ExpressionProps;
+ right: Expression | ExpressionProps;
+ raws?: BinaryOperationExpressionRaws;
+}
+
+/**
+ * Raws indicating how to precisely serialize a {@link BinaryOperationExpression}.
+ *
+ * @category Expression
+ */
+export interface BinaryOperationExpressionRaws {
+ /** The whitespace before the operator. */
+ beforeOperator?: string;
+
+ /** The whitespace after the operator. */
+ afterOperator?: string;
+}
+
+/**
+ * An expression representing an inline binary operation Sass.
+ *
+ * @category Expression
+ */
+export class BinaryOperationExpression extends Expression {
+ readonly sassType = 'binary-operation' as const;
+ declare raws: BinaryOperationExpressionRaws;
+
+ /**
+ * Which operator this operation uses.
+ *
+ * Note that different operators have different precedence. It's the caller's
+ * responsibility to ensure that operations are parenthesized appropriately to
+ * guarantee that they're processed in AST order.
+ */
+ get operator(): BinaryOperator {
+ return this._operator;
+ }
+ set operator(operator: BinaryOperator) {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ this._operator = operator;
+ }
+ private _operator!: BinaryOperator;
+
+ /** The expression on the left-hand side of this operation. */
+ get left(): Expression {
+ return this._left;
+ }
+ set left(left: Expression | ExpressionProps) {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ if (this._left) this._left.parent = undefined;
+ if (!('sassType' in left)) left = fromProps(left);
+ left.parent = this;
+ this._left = left;
+ }
+ private _left!: Expression;
+
+ /** The expression on the right-hand side of this operation. */
+ get right(): Expression {
+ return this._right;
+ }
+ set right(right: Expression | ExpressionProps) {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ if (this._right) this._right.parent = undefined;
+ if (!('sassType' in right)) right = fromProps(right);
+ right.parent = this;
+ this._right = right;
+ }
+ private _right!: Expression;
+
+ constructor(defaults: BinaryOperationExpressionProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.BinaryOperationExpression);
+ constructor(
+ defaults?: object,
+ inner?: sassInternal.BinaryOperationExpression
+ ) {
+ super(defaults);
+ if (inner) {
+ this.source = new LazySource(inner);
+ this.operator = inner.operator.operator;
+ this.left = convertExpression(inner.left);
+ this.right = convertExpression(inner.right);
+ }
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(this, overrides, [
+ 'raws',
+ 'operator',
+ 'left',
+ 'right',
+ ]);
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(this, ['operator', 'left', 'right'], inputs);
+ }
+
+ /** @hidden */
+ toString(): string {
+ return (
+ `${this.left}${this.raws.beforeOperator ?? ' '}${this.operator}` +
+ `${this.raws.afterOperator ?? ' '}${this.right}`
+ );
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [this.left, this.right];
+ }
+}
diff --git a/pkg/sass-parser/lib/src/expression/convert.ts b/pkg/sass-parser/lib/src/expression/convert.ts
new file mode 100644
index 000000000..792a74b11
--- /dev/null
+++ b/pkg/sass-parser/lib/src/expression/convert.ts
@@ -0,0 +1,23 @@
+// 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 sassInternal from '../sass-internal';
+
+import {BinaryOperationExpression} from './binary-operation';
+import {StringExpression} from './string';
+import {Expression} from '.';
+
+/** The visitor to use to convert internal Sass nodes to JS. */
+const visitor = sassInternal.createExpressionVisitor({
+ visitBinaryOperationExpression: inner =>
+ new BinaryOperationExpression(undefined, inner),
+ visitStringExpression: inner => new StringExpression(undefined, inner),
+});
+
+/** Converts an internal expression AST node into an external one. */
+export function convertExpression(
+ expression: sassInternal.Expression
+): Expression {
+ return expression.accept(visitor);
+}
diff --git a/pkg/sass-parser/lib/src/expression/from-props.ts b/pkg/sass-parser/lib/src/expression/from-props.ts
new file mode 100644
index 000000000..030684e52
--- /dev/null
+++ b/pkg/sass-parser/lib/src/expression/from-props.ts
@@ -0,0 +1,14 @@
+// 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 {BinaryOperationExpression} from './binary-operation';
+import {Expression, ExpressionProps} from '.';
+import {StringExpression} from './string';
+
+/** Constructs an expression from {@link ExpressionProps}. */
+export function fromProps(props: ExpressionProps): Expression {
+ if ('text' in props) return new StringExpression(props);
+ if ('left' in props) return new BinaryOperationExpression(props);
+ throw new Error(`Unknown node type: ${props}`);
+}
diff --git a/pkg/sass-parser/lib/src/expression/index.ts b/pkg/sass-parser/lib/src/expression/index.ts
new file mode 100644
index 000000000..a5f599133
--- /dev/null
+++ b/pkg/sass-parser/lib/src/expression/index.ts
@@ -0,0 +1,47 @@
+// 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 {Node} from '../node';
+import type {
+ BinaryOperationExpression,
+ BinaryOperationExpressionProps,
+} from './binary-operation';
+import type {StringExpression, StringExpressionProps} from './string';
+
+/**
+ * The union type of all Sass expressions.
+ *
+ * @category Expression
+ */
+export type AnyExpression = BinaryOperationExpression | StringExpression;
+
+/**
+ * Sass expression types.
+ *
+ * @category Expression
+ */
+export type ExpressionType = 'binary-operation' | 'string';
+
+/**
+ * The union type of all properties that can be used to construct Sass
+ * expressions.
+ *
+ * @category Expression
+ */
+export type ExpressionProps =
+ | BinaryOperationExpressionProps
+ | StringExpressionProps;
+
+/**
+ * The superclass of Sass expression nodes.
+ *
+ * An expressions is anything that can appear in a variable value,
+ * interpolation, declaration value, and so on.
+ *
+ * @category Expression
+ */
+export abstract class Expression extends Node {
+ abstract readonly sassType: ExpressionType;
+ abstract clone(overrides?: object): this;
+}
diff --git a/pkg/sass-parser/lib/src/expression/string.test.ts b/pkg/sass-parser/lib/src/expression/string.test.ts
new file mode 100644
index 000000000..eb5d99074
--- /dev/null
+++ b/pkg/sass-parser/lib/src/expression/string.test.ts
@@ -0,0 +1,332 @@
+// 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 {Interpolation, StringExpression} from '../..';
+import * as utils from '../../../test/utils';
+
+describe('a string expression', () => {
+ let node: StringExpression;
+ describe('quoted', () => {
+ function describeNode(
+ description: string,
+ create: () => StringExpression
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has sassType string', () => expect(node.sassType).toBe('string'));
+
+ it('has quotes', () => expect(node.quotes).toBe(true));
+
+ it('has text', () => expect(node).toHaveInterpolation('text', 'foo'));
+ });
+ }
+
+ describeNode('parsed', () => utils.parseExpression('"foo"'));
+
+ describe('constructed manually', () => {
+ describeNode(
+ 'with explicit text',
+ () =>
+ new StringExpression({
+ quotes: true,
+ text: new Interpolation({nodes: ['foo']}),
+ })
+ );
+
+ describeNode(
+ 'with string text',
+ () =>
+ new StringExpression({
+ quotes: true,
+ text: 'foo',
+ })
+ );
+ });
+
+ describe('constructed from ExpressionProps', () => {
+ describeNode('with explicit text', () =>
+ utils.fromExpressionProps({
+ quotes: true,
+ text: new Interpolation({nodes: ['foo']}),
+ })
+ );
+
+ describeNode('with string text', () =>
+ utils.fromExpressionProps({
+ quotes: true,
+ text: 'foo',
+ })
+ );
+ });
+ });
+
+ describe('unquoted', () => {
+ function describeNode(
+ description: string,
+ create: () => StringExpression
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has sassType string', () => expect(node.sassType).toBe('string'));
+
+ it('has no quotes', () => expect(node.quotes).toBe(false));
+
+ it('has text', () => expect(node).toHaveInterpolation('text', 'foo'));
+ });
+ }
+
+ describeNode('parsed', () => utils.parseExpression('foo'));
+
+ describe('constructed manually', () => {
+ describeNode(
+ 'with explicit text',
+ () =>
+ new StringExpression({
+ text: new Interpolation({nodes: ['foo']}),
+ })
+ );
+
+ describeNode(
+ 'with explicit quotes',
+ () =>
+ new StringExpression({
+ quotes: false,
+ text: 'foo',
+ })
+ );
+
+ describeNode(
+ 'with string text',
+ () =>
+ new StringExpression({
+ text: 'foo',
+ })
+ );
+ });
+
+ describe('constructed from ExpressionProps', () => {
+ describeNode('with explicit text', () =>
+ utils.fromExpressionProps({
+ text: new Interpolation({nodes: ['foo']}),
+ })
+ );
+
+ describeNode('with explicit quotes', () =>
+ utils.fromExpressionProps({
+ quotes: false,
+ text: 'foo',
+ })
+ );
+
+ describeNode('with string text', () =>
+ utils.fromExpressionProps({
+ text: 'foo',
+ })
+ );
+ });
+ });
+
+ describe('assigned new', () => {
+ beforeEach(() => void (node = utils.parseExpression('"foo"')));
+
+ it('quotes', () => {
+ node.quotes = false;
+ expect(node.quotes).toBe(false);
+ });
+
+ describe('text', () => {
+ it("removes the old text's parent", () => {
+ const oldText = node.text;
+ node.text = 'zip';
+ expect(oldText.parent).toBeUndefined();
+ });
+
+ it('assigns text explicitly', () => {
+ const text = new Interpolation({nodes: ['zip']});
+ node.text = text;
+ expect(node.text).toBe(text);
+ expect(node).toHaveInterpolation('text', 'zip');
+ });
+
+ it('assigns text as string', () => {
+ node.text = 'zip';
+ expect(node).toHaveInterpolation('text', 'zip');
+ });
+ });
+ });
+
+ describe('stringifies', () => {
+ describe('quoted', () => {
+ describe('with no internal quotes', () => {
+ beforeEach(() => void (node = utils.parseExpression('"foo"')));
+
+ it('without raws', () => expect(node.toString()).toBe('"foo"'));
+
+ it('with explicit double quotes', () => {
+ node.raws.quotes = '"';
+ expect(node.toString()).toBe('"foo"');
+ });
+
+ it('with explicit single quotes', () => {
+ node.raws.quotes = "'";
+ expect(node.toString()).toBe("'foo'");
+ });
+ });
+
+ describe('with internal double quote', () => {
+ beforeEach(() => void (node = utils.parseExpression("'f\"o'")));
+
+ it('without raws', () => expect(node.toString()).toBe('"f\\"o"'));
+
+ it('with explicit double quotes', () => {
+ node.raws.quotes = '"';
+ expect(node.toString()).toBe('"f\\"o"');
+ });
+
+ it('with explicit single quotes', () => {
+ node.raws.quotes = "'";
+ expect(node.toString()).toBe("'f\"o'");
+ });
+ });
+
+ describe('with internal single quote', () => {
+ beforeEach(() => void (node = utils.parseExpression('"f\'o"')));
+
+ it('without raws', () => expect(node.toString()).toBe('"f\'o"'));
+
+ it('with explicit double quotes', () => {
+ node.raws.quotes = '"';
+ expect(node.toString()).toBe('"f\'o"');
+ });
+
+ it('with explicit single quotes', () => {
+ node.raws.quotes = "'";
+ expect(node.toString()).toBe("'f\\'o'");
+ });
+ });
+
+ it('with internal unprintable', () =>
+ expect(
+ new StringExpression({quotes: true, text: '\x00'}).toString()
+ ).toBe('"\\0 "'));
+
+ it('with internal newline', () =>
+ expect(
+ new StringExpression({quotes: true, text: '\x0A'}).toString()
+ ).toBe('"\\a "'));
+
+ it('with internal backslash', () =>
+ expect(
+ new StringExpression({quotes: true, text: '\\'}).toString()
+ ).toBe('"\\\\"'));
+
+ it('respects interpolation raws', () =>
+ expect(
+ new StringExpression({
+ quotes: true,
+ text: new Interpolation({
+ nodes: ['foo'],
+ raws: {text: [{raw: 'f\\6f o', value: 'foo'}]},
+ }),
+ }).toString()
+ ).toBe('"f\\6f o"'));
+ });
+
+ describe('unquoted', () => {
+ it('prints the text as-is', () =>
+ expect(utils.parseExpression('foo').toString()).toBe('foo'));
+
+ it('with internal quotes', () =>
+ expect(new StringExpression({text: '"'}).toString()).toBe('"'));
+
+ it('with internal newline', () =>
+ expect(new StringExpression({text: '\x0A'}).toString()).toBe('\x0A'));
+
+ it('with internal backslash', () =>
+ expect(new StringExpression({text: '\\'}).toString()).toBe('\\'));
+
+ it('respects interpolation raws', () =>
+ expect(
+ new StringExpression({
+ text: new Interpolation({
+ nodes: ['foo'],
+ raws: {text: [{raw: 'f\\6f o', value: 'foo'}]},
+ }),
+ }).toString()
+ ).toBe('f\\6f o'));
+ });
+ });
+
+ describe('clone', () => {
+ let original: StringExpression;
+ beforeEach(() => {
+ original = utils.parseExpression('"foo"');
+ // TODO: remove this once raws are properly parsed
+ original.raws.quotes = "'";
+ });
+
+ describe('with no overrides', () => {
+ let clone: StringExpression;
+ beforeEach(() => void (clone = original.clone()));
+
+ describe('has the same properties:', () => {
+ it('quotes', () => expect(clone.quotes).toBe(true));
+
+ it('text', () => expect(clone).toHaveInterpolation('text', 'foo'));
+
+ it('raws', () => expect(clone.raws).toEqual({quotes: "'"}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of ['text', 'raws'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+ });
+
+ describe('overrides', () => {
+ describe('quotes', () => {
+ it('defined', () =>
+ expect(original.clone({quotes: false}).quotes).toBe(false));
+
+ it('undefined', () =>
+ expect(original.clone({quotes: undefined}).quotes).toBe(true));
+ });
+
+ describe('text', () => {
+ it('defined', () =>
+ expect(original.clone({text: 'zip'})).toHaveInterpolation(
+ 'text',
+ 'zip'
+ ));
+
+ it('undefined', () =>
+ expect(original.clone({text: undefined})).toHaveInterpolation(
+ 'text',
+ 'foo'
+ ));
+ });
+
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {quotes: '"'}}).raws).toEqual({
+ quotes: '"',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ quotes: "'",
+ }));
+ });
+ });
+ });
+
+ it('toJSON', () => expect(utils.parseExpression('"foo"')).toMatchSnapshot());
+});
diff --git a/pkg/sass-parser/lib/src/expression/string.ts b/pkg/sass-parser/lib/src/expression/string.ts
new file mode 100644
index 000000000..de796e807
--- /dev/null
+++ b/pkg/sass-parser/lib/src/expression/string.ts
@@ -0,0 +1,203 @@
+// 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 {Interpolation} from '../interpolation';
+import {LazySource} from '../lazy-source';
+import type * as sassInternal from '../sass-internal';
+import * as utils from '../utils';
+import {Expression} from '.';
+
+/**
+ * The initializer properties for {@link StringExpression}.
+ *
+ * @category Expression
+ */
+export interface StringExpressionProps {
+ text: Interpolation | string;
+ quotes?: boolean;
+ raws?: StringExpressionRaws;
+}
+
+/**
+ * Raws indicating how to precisely serialize a {@link StringExpression}.
+ *
+ * @category Expression
+ */
+export interface StringExpressionRaws {
+ /**
+ * The type of quotes to use (single or double).
+ *
+ * This is ignored if the string isn't quoted.
+ */
+ quotes?: '"' | "'";
+}
+
+/**
+ * An expression representing a (quoted or unquoted) string literal in Sass.
+ *
+ * @category Expression
+ */
+export class StringExpression extends Expression {
+ readonly sassType = 'string' as const;
+ declare raws: StringExpressionRaws;
+
+ /** The interpolation that represents the text of this string. */
+ get text(): Interpolation {
+ return this._text;
+ }
+ set text(text: Interpolation | string) {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ if (this._text) this._text.parent = undefined;
+ if (typeof text === 'string') text = new Interpolation({nodes: [text]});
+ text.parent = this;
+ this._text = text;
+ }
+ private _text!: Interpolation;
+
+ // TODO: provide a utility asPlainIdentifier method that returns the value of
+ // an identifier with any escapes resolved, if this is indeed a valid unquoted
+ // identifier.
+
+ /**
+ * Whether this is a quoted or unquoted string. Defaults to false.
+ *
+ * Unquoted strings are most commonly used to represent identifiers, but they
+ * can also be used for string-like functions such as `url()` or more unusual
+ * constructs like Unicode ranges.
+ */
+ get quotes(): boolean {
+ return this._quotes;
+ }
+ set quotes(quotes: boolean) {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ this._quotes = quotes;
+ }
+ private _quotes!: boolean;
+
+ constructor(defaults: StringExpressionProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.StringExpression);
+ constructor(defaults?: object, inner?: sassInternal.StringExpression) {
+ super(defaults);
+ if (inner) {
+ this.source = new LazySource(inner);
+ this.text = new Interpolation(undefined, inner.text);
+ this.quotes = inner.hasQuotes;
+ } else {
+ this._quotes ??= false;
+ }
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(this, overrides, ['raws', 'text', 'quotes']);
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(this, ['text', 'quotes'], inputs);
+ }
+
+ /** @hidden */
+ toString(): string {
+ const quote = this.quotes ? this.raws.quotes ?? '"' : '';
+ let result = quote;
+ const rawText = this.text.raws.text;
+ const rawExpressions = this.text.raws.expressions;
+ for (let i = 0; i < this.text.nodes.length; i++) {
+ const element = this.text.nodes[i];
+ if (typeof element === 'string') {
+ const raw = rawText?.[i];
+ // The Dart Sass AST preserves string escapes for unquoted strings
+ // because they serve a dual purpose at runtime of representing
+ // identifiers (which may contain escape codes) and being a catch-all
+ // representation for unquoted non-identifier values such as `url()`s.
+ // As such, escapes in unquoted strings are represented literally.
+ result +=
+ raw?.value === element
+ ? raw.raw
+ : this.quotes
+ ? this.#escapeQuoted(element)
+ : element;
+ } else {
+ const raw = rawExpressions?.[i];
+ result +=
+ '#{' + (raw?.before ?? '') + element + (raw?.after ?? '') + '}';
+ }
+ }
+ return result + quote;
+ }
+
+ /** Escapes a text component of a quoted string literal. */
+ #escapeQuoted(text: string): string {
+ const quote = this.raws.quotes ?? '"';
+ let result = '';
+ for (let i = 0; i < text.length; i++) {
+ const char = text[i];
+ switch (char) {
+ case '"':
+ result += quote === '"' ? '\\"' : '"';
+ break;
+
+ case "'":
+ result += quote === "'" ? "\\'" : "'";
+ break;
+
+ // Write newline characters and unprintable ASCII characters as escapes.
+ case '\x00':
+ case '\x01':
+ case '\x02':
+ case '\x03':
+ case '\x04':
+ case '\x05':
+ case '\x06':
+ case '\x07':
+ case '\x08':
+ case '\x09':
+ case '\x0A':
+ case '\x0B':
+ case '\x0C':
+ case '\x0D':
+ case '\x0E':
+ case '\x0F':
+ case '\x10':
+ case '\x11':
+ case '\x12':
+ case '\x13':
+ case '\x14':
+ case '\x15':
+ case '\x16':
+ case '\x17':
+ case '\x18':
+ case '\x19':
+ case '\x1A':
+ case '\x1B':
+ case '\x1C':
+ case '\x1D':
+ case '\x1E':
+ case '\x1F':
+ case '\x7F':
+ result += '\\' + char.charCodeAt(0).toString(16) + ' ';
+ break;
+
+ case '\\':
+ result += '\\\\';
+ break;
+
+ default:
+ result += char;
+ break;
+ }
+ }
+ return result;
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [this.text];
+ }
+}
diff --git a/pkg/sass-parser/lib/src/interpolation.test.ts b/pkg/sass-parser/lib/src/interpolation.test.ts
new file mode 100644
index 000000000..f5ec6684d
--- /dev/null
+++ b/pkg/sass-parser/lib/src/interpolation.test.ts
@@ -0,0 +1,636 @@
+// 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 {
+ Expression,
+ GenericAtRule,
+ Interpolation,
+ StringExpression,
+ css,
+ scss,
+} from '..';
+
+type EachFn = Parameters[0];
+
+let node: Interpolation;
+describe('an interpolation', () => {
+ describe('empty', () => {
+ function describeNode(
+ description: string,
+ create: () => Interpolation
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has sassType interpolation', () =>
+ expect(node.sassType).toBe('interpolation'));
+
+ it('has no nodes', () => expect(node.nodes).toHaveLength(0));
+
+ it('is plain', () => expect(node.isPlain).toBe(true));
+
+ it('has a plain value', () => expect(node.asPlain).toBe(''));
+ });
+ }
+
+ // TODO: Are there any node types that allow empty interpolation?
+
+ describeNode('constructed manually', () => new Interpolation());
+ });
+
+ describe('with no expressions', () => {
+ function describeNode(
+ description: string,
+ create: () => Interpolation
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has sassType interpolation', () =>
+ expect(node.sassType).toBe('interpolation'));
+
+ it('has a single node', () => {
+ expect(node.nodes).toHaveLength(1);
+ expect(node.nodes[0]).toBe('foo');
+ });
+
+ it('is plain', () => expect(node.isPlain).toBe(true));
+
+ it('has a plain value', () => expect(node.asPlain).toBe('foo'));
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => (scss.parse('@foo').nodes[0] as GenericAtRule).nameInterpolation
+ );
+
+ describeNode(
+ 'parsed as CSS',
+ () => (css.parse('@foo').nodes[0] as GenericAtRule).nameInterpolation
+ );
+
+ describeNode(
+ 'constructed manually',
+ () => new Interpolation({nodes: ['foo']})
+ );
+ });
+
+ describe('with only an expression', () => {
+ function describeNode(
+ description: string,
+ create: () => Interpolation
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has sassType interpolation', () =>
+ expect(node.sassType).toBe('interpolation'));
+
+ it('has a single node', () =>
+ expect(node).toHaveStringExpression(0, 'foo'));
+
+ it('is not plain', () => expect(node.isPlain).toBe(false));
+
+ it('has no plain value', () => expect(node.asPlain).toBe(null));
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => (scss.parse('@#{foo}').nodes[0] as GenericAtRule).nameInterpolation
+ );
+
+ describeNode(
+ 'constructed manually',
+ () => new Interpolation({nodes: [{text: 'foo'}]})
+ );
+ });
+
+ describe('with mixed text and expressions', () => {
+ function describeNode(
+ description: string,
+ create: () => Interpolation
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has sassType interpolation', () =>
+ expect(node.sassType).toBe('interpolation'));
+
+ it('has multiple nodes', () => {
+ expect(node.nodes).toHaveLength(3);
+ expect(node.nodes[0]).toBe('foo');
+ expect(node).toHaveStringExpression(1, 'bar');
+ expect(node.nodes[2]).toBe('baz');
+ });
+
+ it('is not plain', () => expect(node.isPlain).toBe(false));
+
+ it('has no plain value', () => expect(node.asPlain).toBe(null));
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () =>
+ (scss.parse('@foo#{bar}baz').nodes[0] as GenericAtRule)
+ .nameInterpolation
+ );
+
+ describeNode(
+ 'constructed manually',
+ () => new Interpolation({nodes: ['foo', {text: 'bar'}, 'baz']})
+ );
+ });
+
+ describe('can add', () => {
+ beforeEach(() => void (node = new Interpolation()));
+
+ it('a single interpolation', () => {
+ const interpolation = new Interpolation({nodes: ['foo', {text: 'bar'}]});
+ const string = interpolation.nodes[1];
+ node.append(interpolation);
+ expect(node.nodes).toEqual(['foo', string]);
+ expect(string).toHaveProperty('parent', node);
+ expect(interpolation.nodes).toHaveLength(0);
+ });
+
+ it('a list of interpolations', () => {
+ node.append([
+ new Interpolation({nodes: ['foo']}),
+ new Interpolation({nodes: ['bar']}),
+ ]);
+ expect(node.nodes).toEqual(['foo', 'bar']);
+ });
+
+ it('a single expression', () => {
+ const string = new StringExpression({text: 'foo'});
+ node.append(string);
+ expect(node.nodes[0]).toBe(string);
+ expect(string.parent).toBe(node);
+ });
+
+ it('a list of expressions', () => {
+ const string1 = new StringExpression({text: 'foo'});
+ const string2 = new StringExpression({text: 'bar'});
+ node.append([string1, string2]);
+ expect(node.nodes[0]).toBe(string1);
+ expect(node.nodes[1]).toBe(string2);
+ expect(string1.parent).toBe(node);
+ expect(string2.parent).toBe(node);
+ });
+
+ it("a single expression's properties", () => {
+ node.append({text: 'foo'});
+ expect(node).toHaveStringExpression(0, 'foo');
+ });
+
+ it('a list of properties', () => {
+ node.append([{text: 'foo'}, {text: 'bar'}]);
+ expect(node).toHaveStringExpression(0, 'foo');
+ expect(node).toHaveStringExpression(1, 'bar');
+ });
+
+ it('a single string', () => {
+ node.append('foo');
+ expect(node.nodes).toEqual(['foo']);
+ });
+
+ it('a list of strings', () => {
+ node.append(['foo', 'bar']);
+ expect(node.nodes).toEqual(['foo', 'bar']);
+ });
+
+ it('undefined', () => {
+ node.append(undefined);
+ expect(node.nodes).toHaveLength(0);
+ });
+ });
+
+ describe('append', () => {
+ beforeEach(() => void (node = new Interpolation({nodes: ['foo', 'bar']})));
+
+ it('adds multiple children to the end', () => {
+ node.append('baz', 'qux');
+ expect(node.nodes).toEqual(['foo', 'bar', 'baz', 'qux']);
+ });
+
+ it('can be called during iteration', () =>
+ testEachMutation(['foo', 'bar', 'baz'], 0, () => node.append('baz')));
+
+ it('returns itself', () => expect(node.append()).toBe(node));
+ });
+
+ describe('each', () => {
+ beforeEach(() => void (node = new Interpolation({nodes: ['foo', 'bar']})));
+
+ it('calls the callback for each node', () => {
+ const fn: EachFn = jest.fn();
+ node.each(fn);
+ expect(fn).toHaveBeenCalledTimes(2);
+ expect(fn).toHaveBeenNthCalledWith(1, 'foo', 0);
+ expect(fn).toHaveBeenNthCalledWith(2, 'bar', 1);
+ });
+
+ it('returns undefined if the callback is void', () =>
+ expect(node.each(() => {})).toBeUndefined());
+
+ it('returns false and stops iterating if the callback returns false', () => {
+ const fn: EachFn = jest.fn(() => false);
+ expect(node.each(fn)).toBe(false);
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('every', () => {
+ beforeEach(
+ () => void (node = new Interpolation({nodes: ['foo', 'bar', 'baz']}))
+ );
+
+ it('returns true if the callback returns true for all elements', () =>
+ expect(node.every(() => true)).toBe(true));
+
+ it('returns false if the callback returns false for any element', () =>
+ expect(node.every(element => element !== 'bar')).toBe(false));
+ });
+
+ describe('index', () => {
+ beforeEach(
+ () =>
+ void (node = new Interpolation({
+ nodes: ['foo', 'bar', {text: 'baz'}, 'bar'],
+ }))
+ );
+
+ it('returns the first index of a given string', () =>
+ expect(node.index('bar')).toBe(1));
+
+ it('returns the first index of a given expression', () =>
+ expect(node.index(node.nodes[2])).toBe(2));
+
+ it('returns a number as-is', () => expect(node.index(3)).toBe(3));
+ });
+
+ describe('insertAfter', () => {
+ beforeEach(
+ () => void (node = new Interpolation({nodes: ['foo', 'bar', 'baz']}))
+ );
+
+ it('inserts a node after the given element', () => {
+ node.insertAfter('bar', 'qux');
+ expect(node.nodes).toEqual(['foo', 'bar', 'qux', 'baz']);
+ });
+
+ it('inserts a node at the beginning', () => {
+ node.insertAfter(-1, 'qux');
+ expect(node.nodes).toEqual(['qux', 'foo', 'bar', 'baz']);
+ });
+
+ it('inserts a node at the end', () => {
+ node.insertAfter(3, 'qux');
+ expect(node.nodes).toEqual(['foo', 'bar', 'baz', 'qux']);
+ });
+
+ it('inserts multiple nodes', () => {
+ node.insertAfter(1, ['qux', 'qax', 'qix']);
+ expect(node.nodes).toEqual(['foo', 'bar', 'qux', 'qax', 'qix', 'baz']);
+ });
+
+ it('inserts before an iterator', () =>
+ testEachMutation(['foo', 'bar', ['baz', 5]], 1, () =>
+ node.insertAfter(0, ['qux', 'qax', 'qix'])
+ ));
+
+ it('inserts after an iterator', () =>
+ testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () =>
+ node.insertAfter(1, ['qux', 'qax', 'qix'])
+ ));
+
+ it('returns itself', () =>
+ expect(node.insertAfter('foo', 'qux')).toBe(node));
+ });
+
+ describe('insertBefore', () => {
+ beforeEach(
+ () => void (node = new Interpolation({nodes: ['foo', 'bar', 'baz']}))
+ );
+
+ it('inserts a node before the given element', () => {
+ node.insertBefore('bar', 'qux');
+ expect(node.nodes).toEqual(['foo', 'qux', 'bar', 'baz']);
+ });
+
+ it('inserts a node at the beginning', () => {
+ node.insertBefore(0, 'qux');
+ expect(node.nodes).toEqual(['qux', 'foo', 'bar', 'baz']);
+ });
+
+ it('inserts a node at the end', () => {
+ node.insertBefore(4, 'qux');
+ expect(node.nodes).toEqual(['foo', 'bar', 'baz', 'qux']);
+ });
+
+ it('inserts multiple nodes', () => {
+ node.insertBefore(1, ['qux', 'qax', 'qix']);
+ expect(node.nodes).toEqual(['foo', 'qux', 'qax', 'qix', 'bar', 'baz']);
+ });
+
+ it('inserts before an iterator', () =>
+ testEachMutation(['foo', 'bar', ['baz', 5]], 1, () =>
+ node.insertBefore(1, ['qux', 'qax', 'qix'])
+ ));
+
+ it('inserts after an iterator', () =>
+ testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () =>
+ node.insertBefore(2, ['qux', 'qax', 'qix'])
+ ));
+
+ it('returns itself', () =>
+ expect(node.insertBefore('foo', 'qux')).toBe(node));
+ });
+
+ describe('prepend', () => {
+ beforeEach(
+ () => void (node = new Interpolation({nodes: ['foo', 'bar', 'baz']}))
+ );
+
+ it('inserts one node', () => {
+ node.prepend('qux');
+ expect(node.nodes).toEqual(['qux', 'foo', 'bar', 'baz']);
+ });
+
+ it('inserts multiple nodes', () => {
+ node.prepend('qux', 'qax', 'qix');
+ expect(node.nodes).toEqual(['qux', 'qax', 'qix', 'foo', 'bar', 'baz']);
+ });
+
+ it('inserts before an iterator', () =>
+ testEachMutation(['foo', 'bar', ['baz', 5]], 1, () =>
+ node.prepend('qux', 'qax', 'qix')
+ ));
+
+ it('returns itself', () => expect(node.prepend('qux')).toBe(node));
+ });
+
+ describe('push', () => {
+ beforeEach(() => void (node = new Interpolation({nodes: ['foo', 'bar']})));
+
+ it('inserts one node', () => {
+ node.push('baz');
+ expect(node.nodes).toEqual(['foo', 'bar', 'baz']);
+ });
+
+ it('can be called during iteration', () =>
+ testEachMutation(['foo', 'bar', 'baz'], 0, () => node.push('baz')));
+
+ it('returns itself', () => expect(node.push('baz')).toBe(node));
+ });
+
+ describe('removeAll', () => {
+ beforeEach(
+ () =>
+ void (node = new Interpolation({nodes: ['foo', {text: 'bar'}, 'baz']}))
+ );
+
+ it('removes all nodes', () => {
+ node.removeAll();
+ expect(node.nodes).toHaveLength(0);
+ });
+
+ it("removes a node's parents", () => {
+ const string = node.nodes[1];
+ node.removeAll();
+ expect(string).toHaveProperty('parent', undefined);
+ });
+
+ it('can be called during iteration', () =>
+ testEachMutation(['foo'], 0, () => node.removeAll()));
+
+ it('returns itself', () => expect(node.removeAll()).toBe(node));
+ });
+
+ describe('removeChild', () => {
+ beforeEach(
+ () =>
+ void (node = new Interpolation({nodes: ['foo', {text: 'bar'}, 'baz']}))
+ );
+
+ it('removes a matching node', () => {
+ const string = node.nodes[1];
+ node.removeChild('foo');
+ expect(node.nodes).toEqual([string, 'baz']);
+ });
+
+ it('removes a node at index', () => {
+ node.removeChild(1);
+ expect(node.nodes).toEqual(['foo', 'baz']);
+ });
+
+ it("removes a node's parents", () => {
+ const string = node.nodes[1];
+ node.removeAll();
+ expect(string).toHaveProperty('parent', undefined);
+ });
+
+ it('removes a node before the iterator', () =>
+ testEachMutation(['foo', node.nodes[1], ['baz', 1]], 1, () =>
+ node.removeChild(1)
+ ));
+
+ it('removes a node after the iterator', () =>
+ testEachMutation(['foo', node.nodes[1]], 1, () => node.removeChild(2)));
+
+ it('returns itself', () => expect(node.removeChild(0)).toBe(node));
+ });
+
+ describe('some', () => {
+ beforeEach(
+ () => void (node = new Interpolation({nodes: ['foo', 'bar', 'baz']}))
+ );
+
+ it('returns false if the callback returns false for all elements', () =>
+ expect(node.some(() => false)).toBe(false));
+
+ it('returns true if the callback returns true for any element', () =>
+ expect(node.some(element => element === 'bar')).toBe(true));
+ });
+
+ describe('first', () => {
+ it('returns the first element', () =>
+ expect(new Interpolation({nodes: ['foo', 'bar', 'baz']}).first).toBe(
+ 'foo'
+ ));
+
+ it('returns undefined for an empty interpolation', () =>
+ expect(new Interpolation().first).toBeUndefined());
+ });
+
+ describe('last', () => {
+ it('returns the last element', () =>
+ expect(new Interpolation({nodes: ['foo', 'bar', 'baz']}).last).toBe(
+ 'baz'
+ ));
+
+ it('returns undefined for an empty interpolation', () =>
+ expect(new Interpolation().last).toBeUndefined());
+ });
+
+ describe('stringifies', () => {
+ it('with no nodes', () => expect(new Interpolation().toString()).toBe(''));
+
+ it('with only text', () =>
+ expect(new Interpolation({nodes: ['foo', 'bar', 'baz']}).toString()).toBe(
+ 'foobarbaz'
+ ));
+
+ it('with only expressions', () =>
+ expect(
+ new Interpolation({nodes: [{text: 'foo'}, {text: 'bar'}]}).toString()
+ ).toBe('#{foo}#{bar}'));
+
+ it('with mixed text and expressions', () =>
+ expect(
+ new Interpolation({nodes: ['foo', {text: 'bar'}, 'baz']}).toString()
+ ).toBe('foo#{bar}baz'));
+
+ describe('with text', () => {
+ beforeEach(
+ () => void (node = new Interpolation({nodes: ['foo', 'bar', 'baz']}))
+ );
+
+ it('take precedence when the value matches', () => {
+ node.raws.text = [{raw: 'f\\6f o', value: 'foo'}];
+ expect(node.toString()).toBe('f\\6f obarbaz');
+ });
+
+ it("ignored when the value doesn't match", () => {
+ node.raws.text = [{raw: 'f\\6f o', value: 'bar'}];
+ expect(node.toString()).toBe('foobarbaz');
+ });
+ });
+
+ describe('with expressions', () => {
+ beforeEach(
+ () =>
+ void (node = new Interpolation({
+ nodes: [{text: 'foo'}, {text: 'bar'}],
+ }))
+ );
+
+ it('with before', () => {
+ node.raws.expressions = [{before: '/**/'}];
+ expect(node.toString()).toBe('#{/**/foo}#{bar}');
+ });
+
+ it('with after', () => {
+ node.raws.expressions = [{after: '/**/'}];
+ expect(node.toString()).toBe('#{foo/**/}#{bar}');
+ });
+ });
+ });
+
+ describe('clone', () => {
+ let original: Interpolation;
+ beforeEach(
+ () =>
+ void (original = new Interpolation({
+ nodes: ['foo', {text: 'bar'}, 'baz'],
+ raws: {expressions: [{before: ' '}]},
+ }))
+ );
+
+ describe('with no overrides', () => {
+ let clone: Interpolation;
+ beforeEach(() => void (clone = original.clone()));
+
+ describe('has the same properties:', () => {
+ it('nodes', () => {
+ expect(clone.nodes).toHaveLength(3);
+ expect(clone.nodes[0]).toBe('foo');
+ expect(clone.nodes[1]).toHaveInterpolation('text', 'bar');
+ expect(clone.nodes[1]).toHaveProperty('parent', clone);
+ expect(clone.nodes[2]).toBe('baz');
+ });
+
+ it('raws', () =>
+ expect(clone.raws).toEqual({expressions: [{before: ' '}]}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of ['raws', 'nodes'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+
+ describe('sets parent for', () => {
+ it('nodes', () =>
+ expect(clone.nodes[1]).toHaveProperty('parent', clone));
+ });
+ });
+
+ describe('overrides', () => {
+ describe('raws', () => {
+ it('defined', () =>
+ expect(
+ original.clone({raws: {expressions: [{after: ' '}]}}).raws
+ ).toEqual({expressions: [{after: ' '}]}));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ expressions: [{before: ' '}],
+ }));
+ });
+
+ describe('nodes', () => {
+ it('defined', () =>
+ expect(original.clone({nodes: ['qux']}).nodes).toEqual(['qux']));
+
+ it('undefined', () => {
+ const clone = original.clone({nodes: undefined});
+ expect(clone.nodes).toHaveLength(3);
+ expect(clone.nodes[0]).toBe('foo');
+ expect(clone.nodes[1]).toHaveInterpolation('text', 'bar');
+ expect(clone.nodes[1]).toHaveProperty('parent', clone);
+ expect(clone.nodes[2]).toBe('baz');
+ });
+ });
+ });
+ });
+
+ it('toJSON', () =>
+ expect(
+ (scss.parse('@foo#{bar}baz').nodes[0] as GenericAtRule).nameInterpolation
+ ).toMatchSnapshot());
+});
+
+/**
+ * Runs `node.each`, asserting that it sees each element and index in {@link
+ * elements} in order. If an index isn't explicitly provided, it defaults to the
+ * index in {@link elements}.
+ *
+ * When it reaches {@link indexToModify}, it calls {@link modify}, which is
+ * expected to modify `node.nodes`.
+ */
+function testEachMutation(
+ elements: ([string | Expression, number] | string | Expression)[],
+ indexToModify: number,
+ modify: () => void
+): void {
+ const fn: EachFn = jest.fn((child, i) => {
+ if (i === indexToModify) modify();
+ });
+ node.each(fn);
+
+ for (let i = 0; i < elements.length; i++) {
+ const element = elements[i];
+ const [value, index] = Array.isArray(element) ? element : [element, i];
+ expect(fn).toHaveBeenNthCalledWith(i + 1, value, index);
+ }
+ expect(fn).toHaveBeenCalledTimes(elements.length);
+}
diff --git a/pkg/sass-parser/lib/src/interpolation.ts b/pkg/sass-parser/lib/src/interpolation.ts
new file mode 100644
index 000000000..387127e85
--- /dev/null
+++ b/pkg/sass-parser/lib/src/interpolation.ts
@@ -0,0 +1,422 @@
+// 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 {convertExpression} from './expression/convert';
+import {fromProps} from './expression/from-props';
+import {Expression, ExpressionProps} from './expression';
+import {LazySource} from './lazy-source';
+import {Node} from './node';
+import type * as sassInternal from './sass-internal';
+import * as utils from './utils';
+
+/**
+ * The type of new nodes that can be passed into an interpolation.
+ *
+ * @category Expression
+ */
+export type NewNodeForInterpolation =
+ | Interpolation
+ | ReadonlyArray
+ | Expression
+ | ReadonlyArray
+ | ExpressionProps
+ | ReadonlyArray
+ | string
+ | ReadonlyArray
+ | undefined;
+
+/**
+ * The initializer properties for {@link Interpolation}
+ *
+ * @category Expression
+ */
+export interface InterpolationProps {
+ nodes: ReadonlyArray;
+ raws?: InterpolationRaws;
+}
+
+/**
+ * Raws indicating how to precisely serialize an {@link Interpolation} node.
+ *
+ * @category Expression
+ */
+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>;
+
+ /**
+ * The whitespace before and after each interpolated expression.
+ *
+ * Any indices for which {@link Interpolation.nodes} doesn't contain an
+ * expression are ignored.
+ */
+ expressions?: Array<{before?: string; after?: string} | undefined>;
+}
+
+// Note: unlike the Dart Sass interpolation class, this does *not* guarantee
+// that there will be no adjacent strings. Doing so for user modification would
+// cause any active iterators to skip the merged string, and the collapsing
+// doesn't provide a tremendous amount of user benefit.
+
+/**
+ * Sass text that can contian expressions interpolated within it.
+ *
+ * This is not itself an expression. Instead, it's used as a field of
+ * expressions and statements, and acts as a container for further expressions.
+ *
+ * @category Expression
+ */
+export class Interpolation extends Node {
+ readonly sassType = 'interpolation' as const;
+ declare raws: InterpolationRaws;
+
+ /**
+ * An array containing the contents of the interpolation.
+ *
+ * Strings in this array represent the raw text in which interpolation (might)
+ * appear, and expressions represent the interpolated Sass expressions.
+ *
+ * This shouldn't be modified directly; instead, the various methods defined
+ * in {@link Interpolation} should be used to modify it.
+ */
+ get nodes(): ReadonlyArray {
+ return this._nodes!;
+ }
+ /** @hidden */
+ set nodes(nodes: Array) {
+ // This *should* only ever be called by the superclass constructor.
+ this._nodes = nodes;
+ }
+ private _nodes?: Array;
+
+ /** Returns whether this contains no interpolated expressions. */
+ get isPlain(): boolean {
+ return this.asPlain !== null;
+ }
+
+ /**
+ * If this contains no interpolated expressions, returns its text contents.
+ * Otherwise, returns `null`.
+ */
+ get asPlain(): string | null {
+ if (this.nodes.length === 0) return '';
+ if (this.nodes.length !== 1) return null;
+ if (typeof this.nodes[0] !== 'string') return null;
+ return this.nodes[0] as string;
+ }
+
+ /**
+ * Iterators that are currently active within this interpolation. Their
+ * indices refer to the last position that has already been sent to the
+ * callback, and are updated when {@link _nodes} is modified.
+ */
+ readonly #iterators: Array<{index: number}> = [];
+
+ constructor(defaults?: InterpolationProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.Interpolation);
+ constructor(defaults?: object, inner?: sassInternal.Interpolation) {
+ super(defaults);
+ if (inner) {
+ this.source = new LazySource(inner);
+ // TODO: set lazy raws here to use when stringifying
+ this._nodes = [];
+ for (const child of inner.contents) {
+ this.append(
+ typeof child === 'string' ? child : convertExpression(child)
+ );
+ }
+ }
+ if (this._nodes === undefined) this._nodes = [];
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(this, overrides, ['nodes', 'raws']);
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(this, ['nodes'], inputs);
+ }
+
+ /**
+ * Inserts new nodes at the end of this interpolation.
+ *
+ * Note: unlike PostCSS's [`Container.append()`], this treats strings as raw
+ * text rather than parsing them into new nodes.
+ *
+ * [`Container.append()`]: https://postcss.org/api/#container-append
+ */
+ append(...nodes: NewNodeForInterpolation[]): this {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ this._nodes!.push(...this._normalizeList(nodes));
+ return this;
+ }
+
+ /**
+ * Iterates through {@link nodes}, calling `callback` for each child.
+ *
+ * Returning `false` in the callback will break iteration.
+ *
+ * Unlike a `for` loop or `Array#forEach`, this iterator is safe to use while
+ * modifying the interpolation's children.
+ *
+ * @param callback The iterator callback, which is passed each child
+ * @return Returns `false` if any call to `callback` returned false
+ */
+ each(
+ callback: (node: string | Expression, index: number) => false | void
+ ): false | undefined {
+ const iterator = {index: 0};
+ this.#iterators.push(iterator);
+
+ try {
+ while (iterator.index < this.nodes.length) {
+ const result = callback(this.nodes[iterator.index], iterator.index);
+ if (result === false) return false;
+ iterator.index += 1;
+ }
+ return undefined;
+ } finally {
+ this.#iterators.splice(this.#iterators.indexOf(iterator), 1);
+ }
+ }
+
+ /**
+ * Returns `true` if {@link condition} returns `true` for all of the
+ * container’s children.
+ */
+ every(
+ condition: (
+ node: string | Expression,
+ index: number,
+ nodes: ReadonlyArray
+ ) => boolean
+ ): boolean {
+ return this.nodes.every(condition);
+ }
+
+ /**
+ * Returns the first index of {@link child} in {@link nodes}.
+ *
+ * If {@link child} is a number, returns it as-is.
+ */
+ index(child: string | Expression | number): number {
+ return typeof child === 'number' ? child : this.nodes.indexOf(child);
+ }
+
+ /**
+ * Inserts {@link newNode} immediately after the first occurance of
+ * {@link oldNode} in {@link nodes}.
+ *
+ * If {@link oldNode} is a number, inserts {@link newNode} immediately after
+ * that index instead.
+ */
+ insertAfter(
+ oldNode: string | Expression | number,
+ newNode: NewNodeForInterpolation
+ ): this {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ const index = this.index(oldNode);
+ const normalized = this._normalize(newNode);
+ this._nodes!.splice(index + 1, 0, ...normalized);
+
+ for (const iterator of this.#iterators) {
+ if (iterator.index > index) iterator.index += normalized.length;
+ }
+
+ return this;
+ }
+
+ /**
+ * Inserts {@link newNode} immediately before the first occurance of
+ * {@link oldNode} in {@link nodes}.
+ *
+ * If {@link oldNode} is a number, inserts {@link newNode} at that index
+ * instead.
+ */
+ insertBefore(
+ oldNode: string | Expression | number,
+ newNode: NewNodeForInterpolation
+ ): this {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ const index = this.index(oldNode);
+ const normalized = this._normalize(newNode);
+ this._nodes!.splice(index, 0, ...normalized);
+
+ for (const iterator of this.#iterators) {
+ if (iterator.index >= index) iterator.index += normalized.length;
+ }
+
+ return this;
+ }
+
+ /** Inserts {@link nodes} at the beginning of the interpolation. */
+ prepend(...nodes: NewNodeForInterpolation[]): this {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ const normalized = this._normalizeList(nodes);
+ this._nodes!.unshift(...normalized);
+
+ for (const iterator of this.#iterators) {
+ iterator.index += normalized.length;
+ }
+
+ return this;
+ }
+
+ /** Adds {@link child} to the end of this interpolation. */
+ push(child: string | Expression): this {
+ return this.append(child);
+ }
+
+ /**
+ * Removes all {@link nodes} from this interpolation and cleans their {@link
+ * Node.parent} properties.
+ */
+ removeAll(): this {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ for (const node of this.nodes) {
+ if (typeof node !== 'string') node.parent = undefined;
+ }
+ this._nodes!.length = 0;
+ return this;
+ }
+
+ /**
+ * Removes the first occurance of {@link child} from the container and cleans
+ * the parent properties from the node and its children.
+ *
+ * If {@link child} is a number, removes the child at that index.
+ */
+ removeChild(child: string | Expression | number): this {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ const index = this.index(child);
+ if (typeof child === 'object') child.parent = undefined;
+ this._nodes!.splice(index, 1);
+
+ for (const iterator of this.#iterators) {
+ if (iterator.index >= index) iterator.index--;
+ }
+
+ return this;
+ }
+
+ /**
+ * Returns `true` if {@link condition} returns `true` for (at least) one of
+ * the container’s children.
+ */
+ some(
+ condition: (
+ node: string | Expression,
+ index: number,
+ nodes: ReadonlyArray
+ ) => boolean
+ ): boolean {
+ return this.nodes.some(condition);
+ }
+
+ /** The first node in {@link nodes}. */
+ get first(): string | Expression | undefined {
+ return this.nodes[0];
+ }
+
+ /**
+ * The container’s last child.
+ *
+ * ```js
+ * rule.last === rule.nodes[rule.nodes.length - 1]
+ * ```
+ */
+ get last(): string | Expression | undefined {
+ return this.nodes[this.nodes.length - 1];
+ }
+
+ /** @hidden */
+ toString(): string {
+ let result = '';
+
+ const rawText = this.raws.text;
+ const rawExpressions = this.raws.expressions;
+ for (let i = 0; i < this.nodes.length; i++) {
+ const element = this.nodes[i];
+ if (typeof element === 'string') {
+ const raw = rawText?.[i];
+ result += raw?.value === element ? raw.raw : element;
+ } else {
+ const raw = rawExpressions?.[i];
+ result +=
+ '#{' + (raw?.before ?? '') + element + (raw?.after ?? '') + '}';
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Normalizes the many types of node that can be used with Interpolation
+ * methods.
+ */
+ private _normalize(nodes: NewNodeForInterpolation): (Expression | string)[] {
+ const result: Array = [];
+ for (const node of Array.isArray(nodes) ? nodes : [nodes]) {
+ if (node === undefined) {
+ continue;
+ } else if (typeof node === 'string') {
+ if (node.length === 0) continue;
+ result.push(node);
+ } else if ('sassType' in node) {
+ if (node.sassType === 'interpolation') {
+ for (const subnode of node.nodes) {
+ if (typeof subnode === 'string') {
+ if (node.nodes.length === 0) continue;
+ result.push(subnode);
+ } else {
+ subnode.parent = this;
+ result.push(subnode);
+ }
+ }
+ node._nodes!.length = 0;
+ } else {
+ node.parent = this;
+ result.push(node);
+ }
+ } else {
+ const constructed = fromProps(node);
+ constructed.parent = this;
+ result.push(constructed);
+ }
+ }
+ return result;
+ }
+
+ /** Like {@link _normalize}, but also flattens a list of nodes. */
+ private _normalizeList(
+ nodes: ReadonlyArray
+ ): (Expression | string)[] {
+ const result: Array = [];
+ for (const node of nodes) {
+ result.push(...this._normalize(node));
+ }
+ return result;
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return this.nodes.filter(
+ (node): node is Expression => typeof node !== 'string'
+ );
+ }
+}
diff --git a/pkg/sass-parser/lib/src/lazy-source.ts b/pkg/sass-parser/lib/src/lazy-source.ts
new file mode 100644
index 000000000..5deeadb4f
--- /dev/null
+++ b/pkg/sass-parser/lib/src/lazy-source.ts
@@ -0,0 +1,74 @@
+// 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 sass from 'sass';
+import * as postcss from 'postcss';
+import * as url from 'url';
+
+import type * as sassInternal from './sass-internal';
+
+/**
+ * An implementation of `postcss.Source` that lazily fills in the fields when
+ * they're first accessed.
+ */
+export class LazySource implements postcss.Source {
+ /**
+ * The Sass node whose source this covers. We store the whole node rather than
+ * just the span becasue the span itself may be computed lazily.
+ */
+ readonly #inner: sassInternal.SassNode;
+
+ constructor(inner: sassInternal.SassNode) {
+ this.#inner = inner;
+ }
+
+ get start(): postcss.Position | undefined {
+ if (this.#start === 0) {
+ this.#start = locationToPosition(this.#inner.span.start);
+ }
+ return this.#start;
+ }
+ set start(value: postcss.Position | undefined) {
+ this.#start = value;
+ }
+ #start: postcss.Position | undefined | 0 = 0;
+
+ get end(): postcss.Position | undefined {
+ if (this.#end === 0) {
+ this.#end = locationToPosition(this.#inner.span.end);
+ }
+ return this.#end;
+ }
+ set end(value: postcss.Position | undefined) {
+ this.#end = value;
+ }
+ #end: postcss.Position | undefined | 0 = 0;
+
+ get input(): postcss.Input {
+ if (this.#input) return this.#input;
+
+ const sourceFile = this.#inner.span.file;
+ if (sourceFile._postcssInput) return sourceFile._postcssInput;
+
+ const spanUrl = this.#inner.span.url;
+ sourceFile._postcssInput = new postcss.Input(
+ sourceFile.getText(0),
+ spanUrl ? {from: url.fileURLToPath(spanUrl)} : undefined
+ );
+ return sourceFile._postcssInput;
+ }
+ set input(value: postcss.Input) {
+ this.#input = value;
+ }
+ #input: postcss.Input | null = null;
+}
+
+/** Converts a Sass SourceLocation to a PostCSS Position. */
+function locationToPosition(location: sass.SourceLocation): postcss.Position {
+ return {
+ line: location.line + 1,
+ column: location.column + 1,
+ offset: location.offset,
+ };
+}
diff --git a/pkg/sass-parser/lib/src/node.d.ts b/pkg/sass-parser/lib/src/node.d.ts
new file mode 100644
index 000000000..8841a46a0
--- /dev/null
+++ b/pkg/sass-parser/lib/src/node.d.ts
@@ -0,0 +1,98 @@
+// 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 {AnyExpression, ExpressionType} from './expression';
+import {Interpolation} from './interpolation';
+import {AnyStatement, Statement, StatementType} from './statement';
+
+/** The union type of all Sass nodes. */
+export type AnyNode = AnyStatement | AnyExpression | Interpolation;
+
+/**
+ * All Sass node types.
+ *
+ * This is a superset of the node types PostCSS exposes, and is provided
+ * alongside `Node.type` to disambiguate between the wide range of nodes that
+ * Sass parses as distinct types.
+ */
+export type NodeType = StatementType | ExpressionType | 'interpolation';
+
+/** The constructor properties shared by all Sass AST nodes. */
+export type NodeProps = postcss.NodeProps;
+
+/**
+ * Any node in a Sass stylesheet.
+ *
+ * All nodes that Sass can parse implement this type, including expression-level
+ * nodes, selector nodes, and nodes from more domain-specific syntaxes. It aims
+ * to match the PostCSS API as closely as possible while still being generic
+ * enough to work across multiple more than just statements.
+ *
+ * This does _not_ include methods for adding and modifying siblings of this
+ * Node, because these only make sense for expression-level Node types.
+ */
+declare abstract class Node
+ implements
+ Omit<
+ postcss.Node,
+ | 'after'
+ | 'assign'
+ | 'before'
+ | 'clone'
+ | 'cloneAfter'
+ | 'cloneBefore'
+ | 'next'
+ | 'prev'
+ | 'remove'
+ // TODO: supporting replaceWith() would be tricky, but it does have
+ // well-defined semantics even without a nodes array and it's awfully
+ // useful. See if we can find a way.
+ | 'replaceWith'
+ | 'type'
+ | 'parent'
+ | 'toString'
+ >
+{
+ abstract readonly sassType: NodeType;
+ parent: Node | undefined;
+ source?: postcss.Source;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ raws: any;
+
+ /**
+ * A list of children of this node, *not* including any {@link Statement}s it
+ * contains. This is used internally to traverse the full AST.
+ *
+ * @hidden
+ */
+ abstract get nonStatementChildren(): ReadonlyArray>;
+
+ constructor(defaults?: object);
+
+ assign(overrides: object): this;
+ cleanRaws(keepBetween?: boolean): void;
+ error(
+ message: string,
+ options?: postcss.NodeErrorOptions
+ ): postcss.CssSyntaxError;
+ positionBy(
+ opts?: Pick
+ ): postcss.Position;
+ positionInside(index: number): postcss.Position;
+ rangeBy(opts?: Pick): {
+ start: postcss.Position;
+ end: postcss.Position;
+ };
+ raw(prop: string, defaultType?: string): string;
+ root(): postcss.Root;
+ toJSON(): object;
+ warn(
+ result: postcss.Result,
+ message: string,
+ options?: postcss.WarningOptions
+ ): postcss.Warning;
+}
diff --git a/pkg/sass-parser/lib/src/node.js b/pkg/sass-parser/lib/src/node.js
new file mode 100644
index 000000000..5ce9c3493
--- /dev/null
+++ b/pkg/sass-parser/lib/src/node.js
@@ -0,0 +1,47 @@
+// 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.
+
+const postcss = require('postcss');
+
+// Define this separately from the declaration so that we can have it inherit
+// all the methods of the base class and make a few of them throw without it
+// showing up in the TypeScript types.
+class Node extends postcss.Node {
+ constructor(defaults = {}) {
+ super(defaults);
+ }
+
+ after() {
+ throw new Error("after() is only supported for Sass statement nodes.");
+ }
+
+ before() {
+ throw new Error("before() is only supported for Sass statement nodes.");
+ }
+
+ cloneAfter() {
+ throw new Error("cloneAfter() is only supported for Sass statement nodes.");
+ }
+
+ cloneBefore() {
+ throw new Error("cloneBefore() is only supported for Sass statement nodes.");
+ }
+
+ next() {
+ throw new Error("next() is only supported for Sass statement nodes.");
+ }
+
+ prev() {
+ throw new Error("prev() is only supported for Sass statement nodes.");
+ }
+
+ remove() {
+ throw new Error("remove() is only supported for Sass statement nodes.");
+ }
+
+ replaceWith() {
+ throw new Error("replaceWith() is only supported for Sass statement nodes.");
+ }
+}
+exports.Node = Node;
diff --git a/pkg/sass-parser/lib/src/postcss.d.ts b/pkg/sass-parser/lib/src/postcss.d.ts
new file mode 100644
index 000000000..5e9bf293a
--- /dev/null
+++ b/pkg/sass-parser/lib/src/postcss.d.ts
@@ -0,0 +1,19 @@
+// 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';
+
+declare module 'postcss' {
+ interface Container {
+ // We need to be able to override this and call it as a super method.
+ // TODO - postcss/postcss#1957: Remove this
+ /** @hidden */
+ normalize(
+ node: string | postcss.ChildProps | postcss.Node,
+ sample: postcss.Node | undefined
+ ): Child[];
+ }
+}
+
+export const isClean: unique symbol;
diff --git a/pkg/sass-parser/lib/src/postcss.js b/pkg/sass-parser/lib/src/postcss.js
new file mode 100644
index 000000000..022a0e3f2
--- /dev/null
+++ b/pkg/sass-parser/lib/src/postcss.js
@@ -0,0 +1,5 @@
+// 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.
+
+exports.isClean = require('postcss/lib/symbols').isClean;
diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts
new file mode 100644
index 000000000..eba4b41cc
--- /dev/null
+++ b/pkg/sass-parser/lib/src/sass-internal.ts
@@ -0,0 +1,129 @@
+// 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 sass from 'sass';
+import * as postcss from 'postcss';
+
+import type * as binaryOperation from './expression/binary-operation';
+
+// Type definitions for internal Sass APIs we're wrapping. We cast the Sass
+// module to this type to access them.
+
+export type Syntax = 'scss' | 'sass' | 'css';
+
+export interface FileSpan extends sass.SourceSpan {
+ readonly file: SourceFile;
+}
+
+export interface SourceFile {
+ /** Node-only extension that we use to avoid re-creating inputs. */
+ _postcssInput?: postcss.Input;
+
+ getText(start: number, end?: number): string;
+}
+
+// There may be a better way to declare this, but I can't figure it out.
+// eslint-disable-next-line @typescript-eslint/no-namespace
+declare namespace SassInternal {
+ function parse(
+ css: string,
+ syntax: Syntax,
+ path?: string,
+ logger?: sass.Logger
+ ): Stylesheet;
+
+ class StatementVisitor {
+ private _fakePropertyToMakeThisAUniqueType1: T;
+ }
+
+ function createStatementVisitor(
+ inner: StatementVisitorObject
+ ): StatementVisitor;
+
+ class ExpressionVisitor {
+ private _fakePropertyToMakeThisAUniqueType2: T;
+ }
+
+ function createExpressionVisitor(
+ inner: ExpressionVisitorObject
+ ): ExpressionVisitor;
+
+ class SassNode {
+ readonly span: FileSpan;
+ }
+
+ class Interpolation extends SassNode {
+ contents: (string | Expression)[];
+ get asPlain(): string | undefined;
+ }
+
+ class Statement extends SassNode {
+ accept(visitor: StatementVisitor): T;
+ }
+
+ class ParentStatement extends Statement {
+ readonly children: T;
+ }
+
+ class AtRule extends ParentStatement {
+ readonly name: Interpolation;
+ readonly value?: Interpolation;
+ }
+
+ class Stylesheet extends ParentStatement {}
+
+ class StyleRule extends ParentStatement {
+ readonly selector: Interpolation;
+ }
+
+ class Expression extends SassNode {
+ accept(visitor: ExpressionVisitor): T;
+ }
+
+ class BinaryOperator {
+ readonly operator: binaryOperation.BinaryOperator;
+ }
+
+ class BinaryOperationExpression extends Expression {
+ readonly operator: BinaryOperator;
+ readonly left: Expression;
+ readonly right: Expression;
+ readonly hasQuotes: boolean;
+ }
+
+ class StringExpression extends Expression {
+ readonly text: Interpolation;
+ readonly hasQuotes: boolean;
+ }
+}
+
+const sassInternal = (
+ sass as unknown as {loadParserExports_(): typeof SassInternal}
+).loadParserExports_();
+
+export type SassNode = SassInternal.SassNode;
+export type Statement = SassInternal.Statement;
+export type ParentStatement =
+ SassInternal.ParentStatement;
+export type AtRule = SassInternal.AtRule;
+export type Stylesheet = SassInternal.Stylesheet;
+export type StyleRule = SassInternal.StyleRule;
+export type Interpolation = SassInternal.Interpolation;
+export type Expression = SassInternal.Expression;
+export type BinaryOperationExpression = SassInternal.BinaryOperationExpression;
+export type StringExpression = SassInternal.StringExpression;
+
+export interface StatementVisitorObject {
+ visitAtRule(node: AtRule): T;
+ visitStyleRule(node: StyleRule): T;
+}
+
+export interface ExpressionVisitorObject {
+ visitBinaryOperationExpression(node: BinaryOperationExpression): T;
+ visitStringExpression(node: StringExpression): T;
+}
+
+export const parse = sassInternal.parse;
+export const createStatementVisitor = sassInternal.createStatementVisitor;
+export const createExpressionVisitor = sassInternal.createExpressionVisitor;
diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap
new file mode 100644
index 000000000..2f4c3dd15
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap
@@ -0,0 +1,82 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`a generic @-rule toJSON with a child 1`] = `
+{
+ "inputs": [
+ {
+ "css": "@foo {@bar}",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "name": "foo",
+ "nameInterpolation": ,
+ "nodes": [
+ <@bar;>,
+ ],
+ "params": "",
+ "raws": {},
+ "sassType": "atrule",
+ "source": <1:1-1:12 in 0>,
+ "type": "atrule",
+}
+`;
+
+exports[`a generic @-rule toJSON with empty children 1`] = `
+{
+ "inputs": [
+ {
+ "css": "@foo {}",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "name": "foo",
+ "nameInterpolation": ,
+ "nodes": [],
+ "params": "",
+ "raws": {},
+ "sassType": "atrule",
+ "source": <1:1-1:8 in 0>,
+ "type": "atrule",
+}
+`;
+
+exports[`a generic @-rule toJSON with params 1`] = `
+{
+ "inputs": [
+ {
+ "css": "@foo bar",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "name": "foo",
+ "nameInterpolation": ,
+ "params": "bar",
+ "paramsInterpolation": ,
+ "raws": {},
+ "sassType": "atrule",
+ "source": <1:1-1:9 in 0>,
+ "type": "atrule",
+}
+`;
+
+exports[`a generic @-rule toJSON without params 1`] = `
+{
+ "inputs": [
+ {
+ "css": "@foo",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "name": "foo",
+ "nameInterpolation": ,
+ "params": "",
+ "raws": {},
+ "sassType": "atrule",
+ "source": <1:1-1:5 in 0>,
+ "type": "atrule",
+}
+`;
diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/root.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/root.test.ts.snap
new file mode 100644
index 000000000..ee16e41cf
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/__snapshots__/root.test.ts.snap
@@ -0,0 +1,37 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`a root node toJSON with children 1`] = `
+{
+ "inputs": [
+ {
+ "css": "@foo",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "name": "foo",
+ "nameInterpolation": ,
+ "params": "",
+ "raws": {},
+ "sassType": "atrule",
+ "source": <1:1-1:5 in 0>,
+ "type": "atrule",
+}
+`;
+
+exports[`a root node toJSON without children 1`] = `
+{
+ "inputs": [
+ {
+ "css": "",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "nodes": [],
+ "raws": {},
+ "sassType": "root",
+ "source": <1:1-1:1 in 0>,
+ "type": "root",
+}
+`;
diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap
new file mode 100644
index 000000000..792fc7e23
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap
@@ -0,0 +1,41 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`a style rule toJSON with a child 1`] = `
+{
+ "inputs": [
+ {
+ "css": ".foo {@bar}",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "nodes": [
+ <@bar;>,
+ ],
+ "raws": {},
+ "sassType": "rule",
+ "selector": ".foo ",
+ "selectorInterpolation": <.foo >,
+ "source": <1:1-1:12 in 0>,
+ "type": "rule",
+}
+`;
+
+exports[`a style rule toJSON with empty children 1`] = `
+{
+ "inputs": [
+ {
+ "css": ".foo {}",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "nodes": [],
+ "raws": {},
+ "sassType": "rule",
+ "selector": ".foo ",
+ "selectorInterpolation": <.foo >,
+ "source": <1:1-1:8 in 0>,
+ "type": "rule",
+}
+`;
diff --git a/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts
new file mode 100644
index 000000000..0cff547e9
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts
@@ -0,0 +1,91 @@
+// 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 {Rule} from './rule';
+import {Root} from './root';
+import {AtRule, ChildNode, ChildProps, Comment, Declaration, NewNode} from '.';
+
+/**
+ * A fake intermediate class to convince TypeScript to use Sass types for
+ * various upstream methods.
+ *
+ * @hidden
+ */
+export class _AtRule<
+ Props extends Partial,
+> extends postcss.AtRule {
+ declare nodes: ChildNode[];
+
+ // Override the PostCSS container types to constrain them to Sass types only.
+ // Unfortunately, there's no way to abstract this out, because anything
+ // mixin-like returns an intersection type which doesn't actually override
+ // parent methods. See microsoft/TypeScript#59394.
+
+ after(newNode: NewNode): this;
+ append(...nodes: NewNode[]): this;
+ assign(overrides: Partial): this;
+ before(newNode: NewNode): this;
+ cloneAfter(overrides?: Partial): this;
+ cloneBefore(overrides?: Partial): this;
+ each(
+ callback: (node: ChildNode, index: number) => false | void
+ ): false | undefined;
+ every(
+ condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean
+ ): boolean;
+ index(child: ChildNode | number): number;
+ insertAfter(oldNode: ChildNode | number, newNode: NewNode): this;
+ insertBefore(oldNode: ChildNode | number, newNode: NewNode): this;
+ next(): ChildNode | undefined;
+ prepend(...nodes: NewNode[]): this;
+ prev(): ChildNode | undefined;
+ push(child: ChildNode): this;
+ removeChild(child: ChildNode | number): this;
+ replaceWith(
+ ...nodes: (
+ | postcss.Node
+ | ReadonlyArray
+ | ChildProps
+ | ReadonlyArray
+ )[]
+ ): this;
+ root(): Root;
+ some(
+ condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean
+ ): boolean;
+ walk(
+ callback: (node: ChildNode, index: number) => false | void
+ ): false | undefined;
+ walkAtRules(
+ nameFilter: RegExp | string,
+ callback: (atRule: AtRule, index: number) => false | void
+ ): false | undefined;
+ walkAtRules(
+ callback: (atRule: AtRule, index: number) => false | void
+ ): false | undefined;
+ walkComments(
+ callback: (comment: Comment, indexed: number) => false | void
+ ): false | undefined;
+ walkComments(
+ callback: (comment: Comment, indexed: number) => false | void
+ ): false | undefined;
+ walkDecls(
+ propFilter: RegExp | string,
+ callback: (decl: Declaration, index: number) => false | void
+ ): false | undefined;
+ walkDecls(
+ callback: (decl: Declaration, index: number) => false | void
+ ): false | undefined;
+ walkRules(
+ selectorFilter: RegExp | string,
+ callback: (rule: Rule, index: number) => false | void
+ ): false | undefined;
+ walkRules(
+ callback: (rule: Rule, index: number) => false | void
+ ): false | undefined;
+ get first(): ChildNode | undefined;
+ get last(): ChildNode | undefined;
+}
diff --git a/pkg/sass-parser/lib/src/statement/at-rule-internal.js b/pkg/sass-parser/lib/src/statement/at-rule-internal.js
new file mode 100644
index 000000000..70634ab1e
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/at-rule-internal.js
@@ -0,0 +1,5 @@
+// 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.
+
+exports._AtRule = require('postcss').AtRule;
diff --git a/pkg/sass-parser/lib/src/statement/container.test.ts b/pkg/sass-parser/lib/src/statement/container.test.ts
new file mode 100644
index 000000000..46ab1fad6
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/container.test.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 {GenericAtRule, Root, Rule} from '../..';
+
+let root: Root;
+describe('a container node', () => {
+ beforeEach(() => {
+ root = new Root();
+ });
+
+ describe('can add', () => {
+ it('a single Sass node', () => {
+ const rule = new Rule({selector: '.foo'});
+ root.append(rule);
+ expect(root.nodes).toEqual([rule]);
+ expect(rule.parent).toBe(root);
+ });
+
+ it('a list of Sass nodes', () => {
+ const rule1 = new Rule({selector: '.foo'});
+ const rule2 = new Rule({selector: '.bar'});
+ root.append([rule1, rule2]);
+ expect(root.nodes).toEqual([rule1, rule2]);
+ expect(rule1.parent).toBe(root);
+ expect(rule2.parent).toBe(root);
+ });
+
+ it('a Sass root node', () => {
+ const rule1 = new Rule({selector: '.foo'});
+ const rule2 = new Rule({selector: '.bar'});
+ const otherRoot = new Root({nodes: [rule1, rule2]});
+ root.append(otherRoot);
+ expect(root.nodes[0]).toBeInstanceOf(Rule);
+ expect(root.nodes[0]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.foo'
+ );
+ expect(root.nodes[1]).toBeInstanceOf(Rule);
+ expect(root.nodes[1]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.bar'
+ );
+ expect(root.nodes[0].parent).toBe(root);
+ expect(root.nodes[1].parent).toBe(root);
+ expect(rule1.parent).toBeUndefined();
+ expect(rule2.parent).toBeUndefined();
+ });
+
+ it('a PostCSS rule node', () => {
+ const node = postcss.parse('.foo {}').nodes[0];
+ root.append(node);
+ expect(root.nodes[0]).toBeInstanceOf(Rule);
+ expect(root.nodes[0]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.foo'
+ );
+ expect(root.nodes[0].parent).toBe(root);
+ expect(root.nodes[0].source).toBe(node.source);
+ expect(node.parent).toBeUndefined();
+ });
+
+ it('a PostCSS at-rule node', () => {
+ const node = postcss.parse('@foo bar').nodes[0];
+ root.append(node);
+ expect(root.nodes[0]).toBeInstanceOf(GenericAtRule);
+ expect(root.nodes[0]).toHaveInterpolation('nameInterpolation', 'foo');
+ expect(root.nodes[0]).toHaveInterpolation('paramsInterpolation', 'bar');
+ expect(root.nodes[0].parent).toBe(root);
+ expect(root.nodes[0].source).toBe(node.source);
+ expect(node.parent).toBeUndefined();
+ });
+
+ it('a list of PostCSS nodes', () => {
+ const rule1 = new postcss.Rule({selector: '.foo'});
+ const rule2 = new postcss.Rule({selector: '.bar'});
+ root.append([rule1, rule2]);
+ expect(root.nodes[0]).toBeInstanceOf(Rule);
+ expect(root.nodes[0]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.foo'
+ );
+ expect(root.nodes[1]).toBeInstanceOf(Rule);
+ expect(root.nodes[1]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.bar'
+ );
+ expect(root.nodes[0].parent).toBe(root);
+ expect(root.nodes[1].parent).toBe(root);
+ expect(rule1.parent).toBeUndefined();
+ expect(rule2.parent).toBeUndefined();
+ });
+
+ it('a PostCSS root node', () => {
+ const rule1 = new postcss.Rule({selector: '.foo'});
+ const rule2 = new postcss.Rule({selector: '.bar'});
+ const otherRoot = new postcss.Root({nodes: [rule1, rule2]});
+ root.append(otherRoot);
+ expect(root.nodes[0]).toBeInstanceOf(Rule);
+ expect(root.nodes[0]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.foo'
+ );
+ expect(root.nodes[1]).toBeInstanceOf(Rule);
+ expect(root.nodes[1]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.bar'
+ );
+ expect(root.nodes[0].parent).toBe(root);
+ expect(root.nodes[1].parent).toBe(root);
+ expect(rule1.parent).toBeUndefined();
+ expect(rule2.parent).toBeUndefined();
+ });
+
+ it("a single Sass node's properties", () => {
+ root.append({selectorInterpolation: '.foo'});
+ expect(root.nodes[0]).toBeInstanceOf(Rule);
+ expect(root.nodes[0]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.foo'
+ );
+ expect(root.nodes[0].parent).toBe(root);
+ });
+
+ it("a single PostCSS node's properties", () => {
+ root.append({selector: '.foo'});
+ expect(root.nodes[0]).toBeInstanceOf(Rule);
+ expect(root.nodes[0]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.foo'
+ );
+ expect(root.nodes[0].parent).toBe(root);
+ });
+
+ it('a list of properties', () => {
+ root.append(
+ {selectorInterpolation: '.foo'},
+ {selectorInterpolation: '.bar'}
+ );
+ expect(root.nodes[0]).toBeInstanceOf(Rule);
+ expect(root.nodes[0]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.foo'
+ );
+ expect(root.nodes[1]).toBeInstanceOf(Rule);
+ expect(root.nodes[1]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.bar'
+ );
+ expect(root.nodes[0].parent).toBe(root);
+ expect(root.nodes[1].parent).toBe(root);
+ });
+
+ it('a plain CSS string', () => {
+ root.append('.foo {}');
+ expect(root.nodes[0]).toBeInstanceOf(Rule);
+ expect(root.nodes[0]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.foo'
+ );
+ expect(root.nodes[0].parent).toBe(root);
+ });
+
+ it('a list of plain CSS strings', () => {
+ root.append(['.foo {}', '.bar {}']);
+ expect(root.nodes[0]).toBeInstanceOf(Rule);
+ expect(root.nodes[0]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.foo'
+ );
+ expect(root.nodes[1]).toBeInstanceOf(Rule);
+ expect(root.nodes[1]).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.bar'
+ );
+ expect(root.nodes[0].parent).toBe(root);
+ expect(root.nodes[1].parent).toBe(root);
+ });
+
+ it('undefined', () => {
+ root.append(undefined);
+ expect(root.nodes).toHaveLength(0);
+ });
+ });
+});
diff --git a/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts b/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts
new file mode 100644
index 000000000..c40dfab62
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts
@@ -0,0 +1,793 @@
+// 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 {GenericAtRule, Interpolation, Root, Rule, css, sass, scss} from '../..';
+import * as utils from '../../../test/utils';
+
+describe('a generic @-rule', () => {
+ let node: GenericAtRule;
+ describe('with no children', () => {
+ describe('with no params', () => {
+ function describeNode(
+ description: string,
+ create: () => GenericAtRule
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has type atrule', () => expect(node.type).toBe('atrule'));
+
+ it('has sassType atrule', () => expect(node.sassType).toBe('atrule'));
+
+ it('has a nameInterpolation', () =>
+ expect(node).toHaveInterpolation('nameInterpolation', 'foo'));
+
+ it('has a name', () => expect(node.name).toBe('foo'));
+
+ it('has no paramsInterpolation', () =>
+ expect(node.paramsInterpolation).toBeUndefined());
+
+ it('has empty params', () => expect(node.params).toBe(''));
+
+ it('has undefined nodes', () => expect(node.nodes).toBeUndefined());
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('@foo').nodes[0] as GenericAtRule
+ );
+
+ describeNode(
+ 'parsed as CSS',
+ () => css.parse('@foo').nodes[0] as GenericAtRule
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('@foo').nodes[0] as GenericAtRule
+ );
+
+ describe('constructed manually', () => {
+ describeNode(
+ 'with a name interpolation',
+ () =>
+ new GenericAtRule({
+ nameInterpolation: new Interpolation({nodes: ['foo']}),
+ })
+ );
+
+ describeNode(
+ 'with a name string',
+ () => new GenericAtRule({name: 'foo'})
+ );
+ });
+
+ describe('constructed from ChildProps', () => {
+ describeNode('with a name interpolation', () =>
+ utils.fromChildProps({
+ nameInterpolation: new Interpolation({nodes: ['foo']}),
+ })
+ );
+
+ describeNode('with a name string', () =>
+ utils.fromChildProps({name: 'foo'})
+ );
+ });
+ });
+
+ describe('with params', () => {
+ function describeNode(
+ description: string,
+ create: () => GenericAtRule
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a name', () => expect(node.name.toString()).toBe('foo'));
+
+ it('has a paramsInterpolation', () =>
+ expect(node).toHaveInterpolation('paramsInterpolation', 'bar'));
+
+ it('has matching params', () => expect(node.params).toBe('bar'));
+
+ it('has undefined nodes', () => expect(node.nodes).toBeUndefined());
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('@foo bar').nodes[0] as GenericAtRule
+ );
+
+ describeNode(
+ 'parsed as CSS',
+ () => css.parse('@foo bar').nodes[0] as GenericAtRule
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('@foo bar').nodes[0] as GenericAtRule
+ );
+
+ describe('constructed manually', () => {
+ describeNode(
+ 'with an interpolation',
+ () =>
+ new GenericAtRule({
+ name: 'foo',
+ paramsInterpolation: new Interpolation({nodes: ['bar']}),
+ })
+ );
+
+ describeNode(
+ 'with a param string',
+ () => new GenericAtRule({name: 'foo', params: 'bar'})
+ );
+ });
+
+ describe('constructed from ChildProps', () => {
+ describeNode('with an interpolation', () =>
+ utils.fromChildProps({
+ name: 'foo',
+ paramsInterpolation: new Interpolation({nodes: ['bar']}),
+ })
+ );
+
+ describeNode('with a param string', () =>
+ utils.fromChildProps({name: 'foo', params: 'bar'})
+ );
+ });
+ });
+ });
+
+ describe('with empty children', () => {
+ describe('with no params', () => {
+ function describeNode(
+ description: string,
+ create: () => GenericAtRule
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a name', () => expect(node.name).toBe('foo'));
+
+ it('has no paramsInterpolation', () =>
+ expect(node.paramsInterpolation).toBeUndefined());
+
+ it('has empty params', () => expect(node.params).toBe(''));
+
+ it('has no nodes', () => expect(node.nodes).toHaveLength(0));
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('@foo {}').nodes[0] as GenericAtRule
+ );
+
+ describeNode(
+ 'parsed as CSS',
+ () => css.parse('@foo {}').nodes[0] as GenericAtRule
+ );
+
+ describeNode(
+ 'constructed manually',
+ () => new GenericAtRule({name: 'foo', nodes: []})
+ );
+
+ describeNode('constructed from ChildProps', () =>
+ utils.fromChildProps({name: 'foo', nodes: []})
+ );
+ });
+
+ describe('with params', () => {
+ function describeNode(
+ description: string,
+ create: () => GenericAtRule
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a name', () => expect(node.name.toString()).toBe('foo'));
+
+ it('has a paramsInterpolation', () =>
+ expect(node).toHaveInterpolation('paramsInterpolation', 'bar '));
+
+ it('has matching params', () => expect(node.params).toBe('bar '));
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('@foo bar {}').nodes[0] as GenericAtRule
+ );
+
+ describeNode(
+ 'parsed as CSS',
+ () => css.parse('@foo bar {}').nodes[0] as GenericAtRule
+ );
+
+ describe('constructed manually', () => {
+ describeNode(
+ 'with params',
+ () =>
+ new GenericAtRule({
+ name: 'foo',
+ params: 'bar ',
+ nodes: [],
+ })
+ );
+
+ describeNode(
+ 'with an interpolation',
+ () =>
+ new GenericAtRule({
+ name: 'foo',
+ paramsInterpolation: new Interpolation({nodes: ['bar ']}),
+ nodes: [],
+ })
+ );
+ });
+
+ describe('constructed from ChildProps', () => {
+ describeNode('with params', () =>
+ utils.fromChildProps({
+ name: 'foo',
+ params: 'bar ',
+ nodes: [],
+ })
+ );
+
+ describeNode('with an interpolation', () =>
+ utils.fromChildProps({
+ name: 'foo',
+ paramsInterpolation: new Interpolation({nodes: ['bar ']}),
+ nodes: [],
+ })
+ );
+ });
+ });
+ });
+
+ describe('with a child', () => {
+ describe('with no params', () => {
+ describe('parsed as Sass', () => {
+ beforeEach(() => {
+ node = sass.parse('@foo\n .bar').nodes[0] as GenericAtRule;
+ });
+
+ it('has a name', () => expect(node.name).toBe('foo'));
+
+ it('has no paramsInterpolation', () =>
+ expect(node.paramsInterpolation).toBeUndefined());
+
+ it('has empty params', () => expect(node.params).toBe(''));
+
+ it('has a child node', () => {
+ expect(node.nodes).toHaveLength(1);
+ expect(node.nodes[0]).toBeInstanceOf(Rule);
+ expect(node.nodes[0]).toHaveProperty('selector', '.bar\n');
+ });
+ });
+ });
+
+ describe('with params', () => {
+ function describeNode(
+ description: string,
+ create: () => GenericAtRule
+ ): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a name', () => expect(node.name.toString()).toBe('foo'));
+
+ it('has a paramsInterpolation', () =>
+ expect(node).toHaveInterpolation('paramsInterpolation', 'bar'));
+
+ it('has matching params', () => expect(node.params).toBe('bar'));
+
+ it('has a child node', () => {
+ expect(node.nodes).toHaveLength(1);
+ expect(node.nodes[0]).toBeInstanceOf(Rule);
+ expect(node.nodes[0]).toHaveProperty('selector', '.baz\n');
+ });
+ });
+ }
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('@foo bar\n .baz').nodes[0] as GenericAtRule
+ );
+
+ describe('constructed manually', () => {
+ describeNode(
+ 'with params',
+ () =>
+ new GenericAtRule({
+ name: 'foo',
+ params: 'bar',
+ nodes: [{selector: '.baz\n'}],
+ })
+ );
+ });
+
+ describe('constructed from ChildProps', () => {
+ describeNode('with params', () =>
+ utils.fromChildProps({
+ name: 'foo',
+ params: 'bar',
+ nodes: [{selector: '.baz\n'}],
+ })
+ );
+ });
+ });
+ });
+
+ describe('assigned new name', () => {
+ beforeEach(() => {
+ node = scss.parse('@foo {}').nodes[0] as GenericAtRule;
+ });
+
+ it("removes the old name's parent", () => {
+ const oldName = node.nameInterpolation!;
+ node.nameInterpolation = 'bar';
+ expect(oldName.parent).toBeUndefined();
+ });
+
+ it("assigns the new interpolation's parent", () => {
+ const interpolation = new Interpolation({nodes: ['bar']});
+ node.nameInterpolation = interpolation;
+ expect(interpolation.parent).toBe(node);
+ });
+
+ it('assigns the interpolation explicitly', () => {
+ const interpolation = new Interpolation({nodes: ['bar']});
+ node.nameInterpolation = interpolation;
+ expect(node.nameInterpolation).toBe(interpolation);
+ });
+
+ it('assigns the interpolation as a string', () => {
+ node.nameInterpolation = 'bar';
+ expect(node).toHaveInterpolation('nameInterpolation', 'bar');
+ });
+
+ it('assigns the interpolation as name', () => {
+ node.name = 'bar';
+ expect(node).toHaveInterpolation('nameInterpolation', 'bar');
+ });
+ });
+
+ describe('assigned new params', () => {
+ beforeEach(() => {
+ node = scss.parse('@foo bar {}').nodes[0] as GenericAtRule;
+ });
+
+ it('removes the old interpolation', () => {
+ node.paramsInterpolation = undefined;
+ expect(node.paramsInterpolation).toBeUndefined();
+ });
+
+ it('removes the old interpolation as undefined params', () => {
+ node.params = undefined;
+ expect(node.params).toBe('');
+ expect(node.paramsInterpolation).toBeUndefined();
+ });
+
+ it('removes the old interpolation as empty string params', () => {
+ node.params = '';
+ expect(node.params).toBe('');
+ expect(node.paramsInterpolation).toBeUndefined();
+ });
+
+ it("removes the old interpolation's parent", () => {
+ const oldParams = node.paramsInterpolation!;
+ node.paramsInterpolation = undefined;
+ expect(oldParams.parent).toBeUndefined();
+ });
+
+ it("assigns the new interpolation's parent", () => {
+ const interpolation = new Interpolation({nodes: ['baz']});
+ node.paramsInterpolation = interpolation;
+ expect(interpolation.parent).toBe(node);
+ });
+
+ it('assigns the interpolation explicitly', () => {
+ const interpolation = new Interpolation({nodes: ['baz']});
+ node.paramsInterpolation = interpolation;
+ expect(node.paramsInterpolation).toBe(interpolation);
+ });
+
+ it('assigns the interpolation as a string', () => {
+ node.paramsInterpolation = 'baz';
+ expect(node).toHaveInterpolation('paramsInterpolation', 'baz');
+ });
+
+ it('assigns the interpolation as params', () => {
+ node.params = 'baz';
+ expect(node).toHaveInterpolation('paramsInterpolation', 'baz');
+ });
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ describe('with undefined nodes', () => {
+ describe('without params', () => {
+ it('with default raws', () =>
+ expect(new GenericAtRule({name: 'foo'}).toString()).toBe('@foo;'));
+
+ it('with afterName', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ raws: {afterName: '/**/'},
+ }).toString()
+ ).toBe('@foo/**/;'));
+
+ it('with afterName', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ raws: {afterName: '/**/'},
+ }).toString()
+ ).toBe('@foo/**/;'));
+
+ it('with between', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ raws: {between: '/**/'},
+ }).toString()
+ ).toBe('@foo/**/;'));
+
+ it('with afterName and between', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ raws: {afterName: '/*afterName*/', between: '/*between*/'},
+ }).toString()
+ ).toBe('@foo/*afterName*//*between*/;'));
+ });
+
+ describe('with params', () => {
+ it('with default raws', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ paramsInterpolation: 'baz',
+ }).toString()
+ ).toBe('@foo baz;'));
+
+ it('with afterName', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ paramsInterpolation: 'baz',
+ raws: {afterName: '/**/'},
+ }).toString()
+ ).toBe('@foo/**/baz;'));
+
+ it('with between', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ paramsInterpolation: 'baz',
+ raws: {between: '/**/'},
+ }).toString()
+ ).toBe('@foo baz/**/;'));
+ });
+
+ it('with after', () =>
+ expect(
+ new GenericAtRule({name: 'foo', raws: {after: '/**/'}}).toString()
+ ).toBe('@foo;'));
+
+ it('with before', () =>
+ expect(
+ new Root({
+ nodes: [new GenericAtRule({name: 'foo', raws: {before: '/**/'}})],
+ }).toString()
+ ).toBe('/**/@foo'));
+ });
+
+ describe('with defined nodes', () => {
+ describe('without params', () => {
+ it('with default raws', () =>
+ expect(new GenericAtRule({name: 'foo', nodes: []}).toString()).toBe(
+ '@foo {}'
+ ));
+
+ it('with afterName', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ raws: {afterName: '/**/'},
+ nodes: [],
+ }).toString()
+ ).toBe('@foo/**/ {}'));
+
+ it('with afterName', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ raws: {afterName: '/**/'},
+ nodes: [],
+ }).toString()
+ ).toBe('@foo/**/ {}'));
+
+ it('with between', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ raws: {between: '/**/'},
+ nodes: [],
+ }).toString()
+ ).toBe('@foo/**/{}'));
+
+ it('with afterName and between', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ raws: {afterName: '/*afterName*/', between: '/*between*/'},
+ nodes: [],
+ }).toString()
+ ).toBe('@foo/*afterName*//*between*/{}'));
+ });
+
+ describe('with params', () => {
+ it('with default raws', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ paramsInterpolation: 'baz',
+ nodes: [],
+ }).toString()
+ ).toBe('@foo baz {}'));
+
+ it('with afterName', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ paramsInterpolation: 'baz',
+ raws: {afterName: '/**/'},
+ nodes: [],
+ }).toString()
+ ).toBe('@foo/**/baz {}'));
+
+ it('with between', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ paramsInterpolation: 'baz',
+ raws: {between: '/**/'},
+ nodes: [],
+ }).toString()
+ ).toBe('@foo baz/**/{}'));
+ });
+
+ describe('with after', () => {
+ it('with no children', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ raws: {after: '/**/'},
+ nodes: [],
+ }).toString()
+ ).toBe('@foo {/**/}'));
+
+ it('with a child', () =>
+ expect(
+ new GenericAtRule({
+ name: 'foo',
+ nodes: [{selector: '.bar'}],
+ raws: {after: '/**/'},
+ }).toString()
+ ).toBe('@foo {\n .bar {}/**/}'));
+ });
+
+ it('with before', () =>
+ expect(
+ new Root({
+ nodes: [
+ new GenericAtRule({
+ name: 'foo',
+ raws: {before: '/**/'},
+ nodes: [],
+ }),
+ ],
+ }).toString()
+ ).toBe('/**/@foo {}'));
+ });
+ });
+ });
+
+ describe('clone', () => {
+ let original: GenericAtRule;
+ beforeEach(() => {
+ original = scss.parse('@foo bar {.baz {}}').nodes[0] as GenericAtRule;
+ // TODO: remove this once raws are properly parsed
+ original.raws.between = ' ';
+ });
+
+ describe('with no overrides', () => {
+ let clone: GenericAtRule;
+ beforeEach(() => void (clone = original.clone()));
+
+ describe('has the same properties:', () => {
+ it('nameInterpolation', () =>
+ expect(clone).toHaveInterpolation('nameInterpolation', 'foo'));
+
+ it('name', () => expect(clone.name).toBe('foo'));
+
+ it('params', () => expect(clone.params).toBe('bar '));
+
+ it('paramsInterpolation', () =>
+ expect(clone).toHaveInterpolation('paramsInterpolation', 'bar '));
+
+ it('raws', () => expect(clone.raws).toEqual({between: ' '}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+
+ it('nodes', () => {
+ expect(clone.nodes).toHaveLength(1);
+ expect(clone.nodes[0]).toBeInstanceOf(Rule);
+ expect(clone.nodes[0]).toHaveProperty('selector', '.baz ');
+ });
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of [
+ 'nameInterpolation',
+ 'paramsInterpolation',
+ 'raws',
+ 'nodes',
+ ] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+
+ describe('sets parent for', () => {
+ it('nodes', () => expect(clone.nodes[0].parent).toBe(clone));
+ });
+ });
+
+ describe('overrides', () => {
+ describe('name', () => {
+ describe('defined', () => {
+ let clone: GenericAtRule;
+ beforeEach(() => {
+ clone = original.clone({name: 'qux'});
+ });
+
+ it('changes name', () => expect(clone.name).toBe('qux'));
+
+ it('changes nameInterpolation', () =>
+ expect(clone).toHaveInterpolation('nameInterpolation', 'qux'));
+ });
+
+ describe('undefined', () => {
+ let clone: GenericAtRule;
+ beforeEach(() => {
+ clone = original.clone({name: undefined});
+ });
+
+ it('preserves name', () => expect(clone.name).toBe('foo'));
+
+ it('preserves nameInterpolation', () =>
+ expect(clone).toHaveInterpolation('nameInterpolation', 'foo'));
+ });
+ });
+
+ describe('nameInterpolation', () => {
+ describe('defined', () => {
+ let clone: GenericAtRule;
+ beforeEach(() => {
+ clone = original.clone({
+ nameInterpolation: new Interpolation({nodes: ['qux']}),
+ });
+ });
+
+ it('changes name', () => expect(clone.name).toBe('qux'));
+
+ it('changes nameInterpolation', () =>
+ expect(clone).toHaveInterpolation('nameInterpolation', 'qux'));
+ });
+
+ describe('undefined', () => {
+ let clone: GenericAtRule;
+ beforeEach(() => {
+ clone = original.clone({nameInterpolation: undefined});
+ });
+
+ it('preserves name', () => expect(clone.name).toBe('foo'));
+
+ it('preserves nameInterpolation', () =>
+ expect(clone).toHaveInterpolation('nameInterpolation', 'foo'));
+ });
+ });
+
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({
+ afterName: ' ',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ between: ' ',
+ }));
+ });
+
+ describe('params', () => {
+ describe('defined', () => {
+ let clone: GenericAtRule;
+ beforeEach(() => {
+ clone = original.clone({params: 'qux'});
+ });
+
+ it('changes params', () => expect(clone.params).toBe('qux'));
+
+ it('changes paramsInterpolation', () =>
+ expect(clone).toHaveInterpolation('paramsInterpolation', 'qux'));
+ });
+
+ describe('undefined', () => {
+ let clone: GenericAtRule;
+ beforeEach(() => {
+ clone = original.clone({params: undefined});
+ });
+
+ it('changes params', () => expect(clone.params).toBe(''));
+
+ it('changes paramsInterpolation', () =>
+ expect(clone.paramsInterpolation).toBeUndefined());
+ });
+ });
+
+ describe('paramsInterpolation', () => {
+ describe('defined', () => {
+ let clone: GenericAtRule;
+ let interpolation: Interpolation;
+ beforeEach(() => {
+ interpolation = new Interpolation({nodes: ['qux']});
+ clone = original.clone({paramsInterpolation: interpolation});
+ });
+
+ it('changes params', () => expect(clone.params).toBe('qux'));
+
+ it('changes paramsInterpolation', () =>
+ expect(clone).toHaveInterpolation('paramsInterpolation', 'qux'));
+ });
+
+ describe('undefined', () => {
+ let clone: GenericAtRule;
+ beforeEach(() => {
+ clone = original.clone({paramsInterpolation: undefined});
+ });
+
+ it('changes params', () => expect(clone.params).toBe(''));
+
+ it('changes paramsInterpolation', () =>
+ expect(clone.paramsInterpolation).toBeUndefined());
+ });
+ });
+ });
+ });
+
+ describe('toJSON', () => {
+ it('without params', () =>
+ expect(scss.parse('@foo').nodes[0]).toMatchSnapshot());
+
+ it('with params', () =>
+ expect(scss.parse('@foo bar').nodes[0]).toMatchSnapshot());
+
+ it('with empty children', () =>
+ expect(scss.parse('@foo {}').nodes[0]).toMatchSnapshot());
+
+ it('with a child', () =>
+ expect(scss.parse('@foo {@bar}').nodes[0]).toMatchSnapshot());
+ });
+});
diff --git a/pkg/sass-parser/lib/src/statement/generic-at-rule.ts b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts
new file mode 100644
index 000000000..c8ec3c745
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts
@@ -0,0 +1,179 @@
+// 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 as PostcssAtRuleRaws} from 'postcss/lib/at-rule';
+
+import {Interpolation} from '../interpolation';
+import {LazySource} from '../lazy-source';
+import type * as sassInternal from '../sass-internal';
+import * as utils from '../utils';
+import {
+ AtRule,
+ ChildNode,
+ ContainerProps,
+ NewNode,
+ Statement,
+ StatementWithChildren,
+ appendInternalChildren,
+ normalize,
+} from '.';
+import {_AtRule} from './at-rule-internal';
+import {interceptIsClean} from './intercept-is-clean';
+import * as sassParser from '../..';
+
+/**
+ * The set of raws supported by {@link GenericAtRule}.
+ *
+ * Sass doesn't support PostCSS's `params` raws, since the param interpolation
+ * is lexed and made directly available to the caller.
+ *
+ * @category Statement
+ */
+export type GenericAtRuleRaws = Omit;
+
+/**
+ * The initializer properties for {@link GenericAtRule}.
+ *
+ * @category Statement
+ */
+export type GenericAtRuleProps = ContainerProps & {
+ raws?: GenericAtRuleRaws;
+} & (
+ | {nameInterpolation: Interpolation | string; name?: never}
+ | {name: string; nameInterpolation?: never}
+ ) &
+ (
+ | {paramsInterpolation?: Interpolation | string; params?: never}
+ | {params?: string | number; paramsInterpolation?: never}
+ );
+
+/**
+ * An `@`-rule that isn't parsed as a more specific type. Extends
+ * [`postcss.AtRule`].
+ *
+ * [`postcss.AtRule`]: https://postcss.org/api/#atrule
+ *
+ * @category Statement
+ */
+export class GenericAtRule
+ extends _AtRule>
+ implements Statement
+{
+ readonly sassType = 'atrule' as const;
+ declare parent: StatementWithChildren | undefined;
+ declare raws: GenericAtRuleRaws;
+
+ get name(): string {
+ return this.nameInterpolation.toString();
+ }
+ set name(value: string) {
+ this.nameInterpolation = value;
+ }
+
+ /**
+ * The interpolation that represents this at-rule's name.
+ */
+ get nameInterpolation(): Interpolation {
+ return this._nameInterpolation!;
+ }
+ set nameInterpolation(nameInterpolation: Interpolation | string) {
+ if (this._nameInterpolation) this._nameInterpolation.parent = undefined;
+ if (typeof nameInterpolation === 'string') {
+ nameInterpolation = new Interpolation({nodes: [nameInterpolation]});
+ }
+ nameInterpolation.parent = this;
+ this._nameInterpolation = nameInterpolation;
+ }
+ private _nameInterpolation?: Interpolation;
+
+ get params(): string {
+ return this.paramsInterpolation?.toString() ?? '';
+ }
+ set params(value: string | number | undefined) {
+ this.paramsInterpolation = value === '' ? undefined : value?.toString();
+ }
+
+ /**
+ * The interpolation that represents this at-rule's parameters, or undefined
+ * if it has no parameters.
+ */
+ get paramsInterpolation(): Interpolation | undefined {
+ return this._paramsInterpolation;
+ }
+ set paramsInterpolation(
+ paramsInterpolation: Interpolation | string | undefined
+ ) {
+ if (this._paramsInterpolation) this._paramsInterpolation.parent = undefined;
+ if (typeof paramsInterpolation === 'string') {
+ paramsInterpolation = new Interpolation({nodes: [paramsInterpolation]});
+ }
+ if (paramsInterpolation) paramsInterpolation.parent = this;
+ this._paramsInterpolation = paramsInterpolation;
+ }
+ private _paramsInterpolation: Interpolation | undefined;
+
+ constructor(defaults: GenericAtRuleProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.AtRule);
+ constructor(defaults?: GenericAtRuleProps, inner?: sassInternal.AtRule) {
+ super(defaults as postcss.AtRuleProps);
+
+ if (inner) {
+ this.source = new LazySource(inner);
+ this.nameInterpolation = new Interpolation(undefined, inner.name);
+ if (inner.value) {
+ this.paramsInterpolation = new Interpolation(undefined, inner.value);
+ }
+ appendInternalChildren(this, inner.children);
+ }
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(
+ this,
+ overrides,
+ [
+ 'nodes',
+ 'raws',
+ 'nameInterpolation',
+ {name: 'paramsInterpolation', explicitUndefined: true},
+ ],
+ ['name', {name: 'params', explicitUndefined: true}]
+ );
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(
+ this,
+ ['name', 'nameInterpolation', 'params', 'paramsInterpolation', 'nodes'],
+ inputs
+ );
+ }
+
+ /** @hidden */
+ toString(
+ stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
+ .stringify
+ ): string {
+ return super.toString(stringifier);
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ const result = [this.nameInterpolation];
+ if (this.paramsInterpolation) result.push(this.paramsInterpolation);
+ return result;
+ }
+
+ /** @hidden */
+ normalize(node: NewNode, sample?: postcss.Node): ChildNode[] {
+ return normalize(this, node, sample);
+ }
+}
+
+interceptIsClean(GenericAtRule);
diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts
new file mode 100644
index 000000000..0f0b79366
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/index.ts
@@ -0,0 +1,213 @@
+// 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 {Node, NodeProps} from '../node';
+import * as sassInternal from '../sass-internal';
+import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule';
+import {Root} from './root';
+import {Rule, RuleProps} from './rule';
+
+// TODO: Replace this with the corresponding Sass types once they're
+// implemented.
+export {Comment, Declaration} from 'postcss';
+
+/**
+ * The union type of all Sass statements.
+ *
+ * @category Statement
+ */
+export type AnyStatement = Root | Rule | GenericAtRule;
+
+/**
+ * Sass statement types.
+ *
+ * This is a superset of the node types PostCSS exposes, and is provided
+ * alongside `Node.type` to disambiguate between the wide range of statements
+ * that Sass parses as distinct types.
+ *
+ * @category Statement
+ */
+export type StatementType = 'root' | 'rule' | 'atrule';
+
+/**
+ * All Sass statements that are also at-rules.
+ *
+ * @category Statement
+ */
+export type AtRule = GenericAtRule;
+
+/**
+ * All Sass statements that are valid children of other statements.
+ *
+ * The Sass equivalent of PostCSS's `ChildNode`.
+ *
+ * @category Statement
+ */
+export type ChildNode = Rule | AtRule;
+
+/**
+ * The properties that can be used to construct {@link ChildNode}s.
+ *
+ * The Sass equivalent of PostCSS's `ChildProps`.
+ *
+ * @category Statement
+ */
+export type ChildProps = postcss.ChildProps | RuleProps | GenericAtRuleProps;
+
+/**
+ * The Sass eqivalent of PostCSS's `ContainerProps`.
+ *
+ * @category Statement
+ */
+export interface ContainerProps extends NodeProps {
+ nodes?: (postcss.Node | ChildProps)[];
+}
+
+/**
+ * A {@link Statement} that has actual child nodes.
+ *
+ * @category Statement
+ */
+export type StatementWithChildren = postcss.Container & {
+ nodes: ChildNode[];
+} & Statement;
+
+/**
+ * A statement in a Sass stylesheet.
+ *
+ * In addition to implementing the standard PostCSS behavior, this provides
+ * extra information to help disambiguate different types that Sass parses
+ * differently.
+ *
+ * @category Statement
+ */
+export interface Statement extends postcss.Node, Node {
+ /** The type of this statement. */
+ readonly sassType: StatementType;
+
+ parent: StatementWithChildren | undefined;
+}
+
+/** The visitor to use to convert internal Sass nodes to JS. */
+const visitor = sassInternal.createStatementVisitor({
+ visitAtRule: inner => new GenericAtRule(undefined, inner),
+ visitStyleRule: inner => new Rule(undefined, inner),
+});
+
+/** Appends parsed versions of `internal`'s children to `container`. */
+export function appendInternalChildren(
+ container: postcss.Container,
+ children: sassInternal.Statement[] | null
+): void {
+ // Make sure `container` knows it has a block.
+ if (children?.length === 0) container.append(undefined);
+ if (!children) return;
+ for (const child of children) {
+ container.append(child.accept(visitor));
+ }
+}
+
+/**
+ * The type of nodes that can be passed as new child nodes to PostCSS methods.
+ */
+export type NewNode =
+ | ChildProps
+ | ReadonlyArray
+ | postcss.Node
+ | ReadonlyArray
+ | string
+ | ReadonlyArray
+ | undefined;
+
+/** PostCSS's built-in normalize function. */
+const postcssNormalize = postcss.Container.prototype.normalize;
+
+/**
+ * A wrapper around {@link postcssNormalize} that converts the results to the
+ * corresponding Sass type(s) after normalizing.
+ */
+function postcssNormalizeAndConvertToSass(
+ self: StatementWithChildren,
+ node: string | postcss.ChildProps | postcss.Node,
+ sample: postcss.Node | undefined
+): ChildNode[] {
+ return postcssNormalize.call(self, node, sample).map(postcssNode => {
+ // postcssNormalize sets the parent to the Sass node, but we don't want to
+ // mix Sass AST nodes with plain PostCSS AST nodes so we unset it in favor
+ // of creating a totally new node.
+ postcssNode.parent = undefined;
+
+ switch (postcssNode.type) {
+ case 'atrule':
+ return new GenericAtRule({
+ name: postcssNode.name,
+ params: postcssNode.params,
+ raws: postcssNode.raws,
+ source: postcssNode.source,
+ });
+ case 'rule':
+ return new Rule({
+ selector: postcssNode.selector,
+ raws: postcssNode.raws,
+ source: postcssNode.source,
+ });
+ default:
+ throw new Error(`Unsupported PostCSS node type ${postcssNode.type}`);
+ }
+ });
+}
+
+/**
+ * An override of {@link postcssNormalize} that supports Sass nodes as arguments
+ * and converts PostCSS-style arguments to Sass.
+ */
+export function normalize(
+ self: StatementWithChildren,
+ node: NewNode,
+ sample?: postcss.Node
+): ChildNode[] {
+ if (node === undefined) return [];
+ const nodes = Array.isArray(node) ? node : [node];
+
+ const result: ChildNode[] = [];
+ for (const node of nodes) {
+ if (typeof node === 'string') {
+ // We could in principle parse these as Sass.
+ result.push(...postcssNormalizeAndConvertToSass(self, node, sample));
+ } else if ('sassType' in node) {
+ if (node.sassType === 'root') {
+ result.push(...(node as Root).nodes);
+ } else {
+ result.push(node as ChildNode);
+ }
+ } else if ('type' in node) {
+ result.push(...postcssNormalizeAndConvertToSass(self, node, sample));
+ } else if (
+ 'selectorInterpolation' in node ||
+ 'selector' in node ||
+ 'selectors' in node
+ ) {
+ result.push(new Rule(node));
+ } else if ('name' in node || 'nameInterpolation' in node) {
+ result.push(new GenericAtRule(node));
+ } else {
+ result.push(...postcssNormalizeAndConvertToSass(self, node, sample));
+ }
+ }
+
+ for (const node of result) {
+ if (node.parent) node.parent.removeChild(node);
+ if (
+ node.raws.before === 'undefined' &&
+ sample?.raws?.before !== undefined
+ ) {
+ node.raws.before = sample.raws.before.replace(/\S/g, '');
+ }
+ node.parent = self;
+ }
+
+ return result;
+}
diff --git a/pkg/sass-parser/lib/src/statement/intercept-is-clean.ts b/pkg/sass-parser/lib/src/statement/intercept-is-clean.ts
new file mode 100644
index 000000000..37e7fc35a
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/intercept-is-clean.ts
@@ -0,0 +1,33 @@
+// 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 {isClean} from '../postcss';
+import type {Node} from '../node';
+import * as utils from '../utils';
+import type {Statement} from '.';
+
+/**
+ * Defines a getter/setter pair for the given {@link klass} that intercepts
+ * PostCSS's attempt to mark it as clean and marks any non-statement children as
+ * clean as well.
+ */
+export function interceptIsClean(
+ klass: utils.Constructor
+): void {
+ Object.defineProperty(klass as typeof klass & {_isClean: boolean}, isClean, {
+ get(): boolean {
+ return this._isClean;
+ },
+ set(value: boolean): void {
+ this._isClean = value;
+ if (value) this.nonStatementChildren.forEach(markClean);
+ },
+ });
+}
+
+/** Marks {@link node} and all its children as clean. */
+function markClean(node: Node): void {
+ (node as Node & {[isClean]: boolean})[isClean] = true;
+ node.nonStatementChildren.forEach(markClean);
+}
diff --git a/pkg/sass-parser/lib/src/statement/root-internal.d.ts b/pkg/sass-parser/lib/src/statement/root-internal.d.ts
new file mode 100644
index 000000000..71a9d6b07
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/root-internal.d.ts
@@ -0,0 +1,89 @@
+// 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 {Rule} from './rule';
+import {Root, RootProps} from './root';
+import {AtRule, ChildNode, ChildProps, Comment, Declaration, NewNode} from '.';
+
+/**
+ * A fake intermediate class to convince TypeScript to use Sass types for
+ * various upstream methods.
+ *
+ * @hidden
+ */
+export class _Root extends postcss.Root {
+ declare nodes: ChildNode[];
+
+ // Override the PostCSS container types to constrain them to Sass types only.
+ // Unfortunately, there's no way to abstract this out, because anything
+ // mixin-like returns an intersection type which doesn't actually override
+ // parent methods. See microsoft/TypeScript#59394.
+
+ after(newNode: NewNode): this;
+ append(...nodes: NewNode[]): this;
+ assign(overrides: Partial): this;
+ before(newNode: NewNode): this;
+ cloneAfter(overrides?: Partial): this;
+ cloneBefore(overrides?: Partial): this;
+ each(
+ callback: (node: ChildNode, index: number) => false | void
+ ): false | undefined;
+ every(
+ condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean
+ ): boolean;
+ index(child: ChildNode | number): number;
+ insertAfter(oldNode: ChildNode | number, newNode: NewNode): this;
+ insertBefore(oldNode: ChildNode | number, newNode: NewNode): this;
+ next(): ChildNode | undefined;
+ prepend(...nodes: NewNode[]): this;
+ prev(): ChildNode | undefined;
+ push(child: ChildNode): this;
+ removeChild(child: ChildNode | number): this;
+ replaceWith(
+ ...nodes: (
+ | postcss.Node
+ | ReadonlyArray
+ | ChildProps
+ | ReadonlyArray
+ )[]
+ ): this;
+ root(): Root;
+ some(
+ condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean
+ ): boolean;
+ walk(
+ callback: (node: ChildNode, index: number) => false | void
+ ): false | undefined;
+ walkAtRules(
+ nameFilter: RegExp | string,
+ callback: (atRule: AtRule, index: number) => false | void
+ ): false | undefined;
+ walkAtRules(
+ callback: (atRule: AtRule, index: number) => false | void
+ ): false | undefined;
+ walkComments(
+ callback: (comment: Comment, indexed: number) => false | void
+ ): false | undefined;
+ walkComments(
+ callback: (comment: Comment, indexed: number) => false | void
+ ): false | undefined;
+ walkDecls(
+ propFilter: RegExp | string,
+ callback: (decl: Declaration, index: number) => false | void
+ ): false | undefined;
+ walkDecls(
+ callback: (decl: Declaration, index: number) => false | void
+ ): false | undefined;
+ walkRules(
+ selectorFilter: RegExp | string,
+ callback: (rule: Rule, index: number) => false | void
+ ): false | undefined;
+ walkRules(
+ callback: (rule: Rule, index: number) => false | void
+ ): false | undefined;
+ get first(): ChildNode | undefined;
+ get last(): ChildNode | undefined;
+}
diff --git a/pkg/sass-parser/lib/src/statement/root-internal.js b/pkg/sass-parser/lib/src/statement/root-internal.js
new file mode 100644
index 000000000..599781530
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/root-internal.js
@@ -0,0 +1,5 @@
+// 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.
+
+exports._Root = require('postcss').Root;
diff --git a/pkg/sass-parser/lib/src/statement/root.test.ts b/pkg/sass-parser/lib/src/statement/root.test.ts
new file mode 100644
index 000000000..2d8d19687
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/root.test.ts
@@ -0,0 +1,159 @@
+// 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 {GenericAtRule, Root, css, sass, scss} from '../..';
+
+describe('a root node', () => {
+ let node: Root;
+ describe('with no children', () => {
+ function describeNode(description: string, create: () => Root): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has type root', () => expect(node.type).toBe('root'));
+
+ it('has sassType root', () => expect(node.sassType).toBe('root'));
+
+ it('has no child nodes', () => expect(node.nodes).toHaveLength(0));
+ });
+ }
+
+ describeNode('parsed as SCSS', () => scss.parse(''));
+ describeNode('parsed as CSS', () => css.parse(''));
+ describeNode('parsed as Sass', () => sass.parse(''));
+ describeNode('constructed manually', () => new Root());
+ });
+
+ describe('with children', () => {
+ function describeNode(description: string, create: () => Root): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has type root', () => expect(node.type).toBe('root'));
+
+ it('has sassType root', () => expect(node.sassType).toBe('root'));
+
+ it('has a child node', () => {
+ expect(node.nodes).toHaveLength(1);
+ expect(node.nodes[0]).toBeInstanceOf(GenericAtRule);
+ expect(node.nodes[0]).toHaveProperty('name', 'foo');
+ });
+ });
+ }
+
+ describeNode('parsed as SCSS', () => scss.parse('@foo'));
+ describeNode('parsed as CSS', () => css.parse('@foo'));
+ describeNode('parsed as Sass', () => sass.parse('@foo'));
+
+ describeNode(
+ 'constructed manually',
+ () => new Root({nodes: [{name: 'foo'}]})
+ );
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ describe('with default raws', () => {
+ it('with no children', () => expect(new Root().toString()).toBe(''));
+
+ it('with a child', () =>
+ expect(new Root({nodes: [{name: 'foo'}]}).toString()).toBe('@foo'));
+ });
+
+ describe('with after', () => {
+ it('with no children', () =>
+ expect(new Root({raws: {after: '/**/'}}).toString()).toBe('/**/'));
+
+ it('with a child', () =>
+ expect(
+ new Root({
+ nodes: [{name: 'foo'}],
+ raws: {after: '/**/'},
+ }).toString()
+ ).toBe('@foo/**/'));
+ });
+
+ describe('with semicolon', () => {
+ it('with no children', () =>
+ expect(new Root({raws: {semicolon: true}}).toString()).toBe(''));
+
+ it('with a child', () =>
+ expect(
+ new Root({
+ nodes: [{name: 'foo'}],
+ raws: {semicolon: true},
+ }).toString()
+ ).toBe('@foo;'));
+ });
+ });
+ });
+
+ describe('clone', () => {
+ let original: Root;
+ beforeEach(() => {
+ original = scss.parse('@foo');
+ // TODO: remove this once raws are properly parsed
+ original.raws.after = ' ';
+ });
+
+ describe('with no overrides', () => {
+ let clone: Root;
+ beforeEach(() => {
+ clone = original.clone();
+ });
+
+ describe('has the same properties:', () => {
+ it('raws', () => expect(clone.raws).toEqual({after: ' '}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+
+ it('nodes', () => {
+ expect(clone.nodes).toHaveLength(1);
+ expect(clone.nodes[0]).toBeInstanceOf(GenericAtRule);
+ expect((clone.nodes[0] as GenericAtRule).name).toBe('foo');
+ });
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of ['raws', 'nodes'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+
+ describe('sets parent for', () => {
+ it('nodes', () => expect(clone.nodes[0].parent).toBe(clone));
+ });
+ });
+
+ describe('overrides', () => {
+ it('nodes', () => {
+ const nodes = original.clone({nodes: [{name: 'bar'}]}).nodes;
+ expect(nodes).toHaveLength(1);
+ expect(nodes[0]).toBeInstanceOf(GenericAtRule);
+ expect(nodes[0]).toHaveProperty('name', 'bar');
+ });
+
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {semicolon: true}}).raws).toEqual({
+ semicolon: true,
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ after: ' ',
+ }));
+ });
+ });
+ });
+
+ describe('toJSON', () => {
+ it('without children', () => expect(scss.parse('')).toMatchSnapshot());
+
+ it('with children', () =>
+ expect(scss.parse('@foo').nodes[0]).toMatchSnapshot());
+ });
+});
diff --git a/pkg/sass-parser/lib/src/statement/root.ts b/pkg/sass-parser/lib/src/statement/root.ts
new file mode 100644
index 000000000..f3b2471a7
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/root.ts
@@ -0,0 +1,81 @@
+// 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 {RootRaws} from 'postcss/lib/root';
+
+import * as sassParser from '../..';
+import {LazySource} from '../lazy-source';
+import type * as sassInternal from '../sass-internal';
+import * as utils from '../utils';
+import {
+ ChildNode,
+ ContainerProps,
+ NewNode,
+ Statement,
+ appendInternalChildren,
+ normalize,
+} from '.';
+import {_Root} from './root-internal';
+
+export type {RootRaws} from 'postcss/lib/root';
+
+/**
+ * The initializer properties for {@link Root}.
+ *
+ * @category Statement
+ */
+export interface RootProps extends ContainerProps {
+ raws?: RootRaws;
+}
+
+/**
+ * The root node of a Sass stylesheet. Extends [`postcss.Root`].
+ *
+ * [`postcss.Root`]: https://postcss.org/api/#root
+ *
+ * @category Statement
+ */
+export class Root extends _Root implements Statement {
+ readonly sassType = 'root' as const;
+ declare parent: undefined;
+ declare raws: RootRaws;
+
+ /** @hidden */
+ readonly nonStatementChildren = [] as const;
+
+ constructor(defaults?: RootProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.Stylesheet);
+ constructor(defaults?: object, inner?: sassInternal.Stylesheet) {
+ super(defaults);
+ if (inner) {
+ this.source = new LazySource(inner);
+ appendInternalChildren(this, inner.children);
+ }
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(this, overrides, ['nodes', 'raws']);
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(this, ['nodes'], inputs);
+ }
+
+ toString(
+ stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
+ .stringify
+ ): string {
+ return super.toString(stringifier);
+ }
+
+ /** @hidden */
+ normalize(node: NewNode, sample?: postcss.Node): ChildNode[] {
+ return normalize(this, node, sample);
+ }
+}
diff --git a/pkg/sass-parser/lib/src/statement/rule-internal.d.ts b/pkg/sass-parser/lib/src/statement/rule-internal.d.ts
new file mode 100644
index 000000000..93818ea91
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/rule-internal.d.ts
@@ -0,0 +1,89 @@
+// 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 {Rule, RuleProps} from './rule';
+import {Root} from './root';
+import {AtRule, ChildNode, ChildProps, Comment, Declaration, NewNode} from '.';
+
+/**
+ * A fake intermediate class to convince TypeScript to use Sass types for
+ * various upstream methods.
+ *
+ * @hidden
+ */
+export class _Rule extends postcss.Rule {
+ declare nodes: ChildNode[];
+
+ // Override the PostCSS container types to constrain them to Sass types only.
+ // Unfortunately, there's no way to abstract this out, because anything
+ // mixin-like returns an intersection type which doesn't actually override
+ // parent methods. See microsoft/TypeScript#59394.
+
+ after(newNode: NewNode): this;
+ append(...nodes: NewNode[]): this;
+ assign(overrides: Partial): this;
+ before(newNode: NewNode): this;
+ cloneAfter(overrides?: Partial): this;
+ cloneBefore(overrides?: Partial): this;
+ each(
+ callback: (node: ChildNode, index: number) => false | void
+ ): false | undefined;
+ every(
+ condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean
+ ): boolean;
+ index(child: ChildNode | number): number;
+ insertAfter(oldNode: ChildNode | number, newNode: NewNode): this;
+ insertBefore(oldNode: ChildNode | number, newNode: NewNode): this;
+ next(): ChildNode | undefined;
+ prepend(...nodes: NewNode[]): this;
+ prev(): ChildNode | undefined;
+ push(child: ChildNode): this;
+ removeChild(child: ChildNode | number): this;
+ replaceWith(
+ ...nodes: (
+ | postcss.Node
+ | ReadonlyArray
+ | ChildProps
+ | ReadonlyArray
+ )[]
+ ): this;
+ root(): Root;
+ some(
+ condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean
+ ): boolean;
+ walk(
+ callback: (node: ChildNode, index: number) => false | void
+ ): false | undefined;
+ walkAtRules(
+ nameFilter: RegExp | string,
+ callback: (atRule: AtRule, index: number) => false | void
+ ): false | undefined;
+ walkAtRules(
+ callback: (atRule: AtRule, index: number) => false | void
+ ): false | undefined;
+ walkComments(
+ callback: (comment: Comment, indexed: number) => false | void
+ ): false | undefined;
+ walkComments(
+ callback: (comment: Comment, indexed: number) => false | void
+ ): false | undefined;
+ walkDecls(
+ propFilter: RegExp | string,
+ callback: (decl: Declaration, index: number) => false | void
+ ): false | undefined;
+ walkDecls(
+ callback: (decl: Declaration, index: number) => false | void
+ ): false | undefined;
+ walkRules(
+ selectorFilter: RegExp | string,
+ callback: (rule: Rule, index: number) => false | void
+ ): false | undefined;
+ walkRules(
+ callback: (rule: Rule, index: number) => false | void
+ ): false | undefined;
+ get first(): ChildNode | undefined;
+ get last(): ChildNode | undefined;
+}
diff --git a/pkg/sass-parser/lib/src/statement/rule-internal.js b/pkg/sass-parser/lib/src/statement/rule-internal.js
new file mode 100644
index 000000000..96d32cce0
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/rule-internal.js
@@ -0,0 +1,5 @@
+// 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.
+
+exports._Rule = require('postcss').Rule;
diff --git a/pkg/sass-parser/lib/src/statement/rule.test.ts b/pkg/sass-parser/lib/src/statement/rule.test.ts
new file mode 100644
index 000000000..3f688fad4
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/rule.test.ts
@@ -0,0 +1,360 @@
+// 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 {GenericAtRule, Interpolation, Root, Rule, css, sass, scss} from '../..';
+import * as utils from '../../../test/utils';
+
+describe('a style rule', () => {
+ let node: Rule;
+ describe('with no children', () => {
+ function describeNode(description: string, create: () => Rule): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has type rule', () => expect(node.type).toBe('rule'));
+
+ it('has sassType rule', () => expect(node.sassType).toBe('rule'));
+
+ it('has matching selectorInterpolation', () =>
+ expect(node).toHaveInterpolation('selectorInterpolation', '.foo '));
+
+ it('has matching selector', () => expect(node.selector).toBe('.foo '));
+
+ it('has empty nodes', () => expect(node.nodes).toHaveLength(0));
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('.foo {}').nodes[0] as Rule
+ );
+
+ describeNode('parsed as CSS', () => css.parse('.foo {}').nodes[0] as Rule);
+
+ describe('parsed as Sass', () => {
+ beforeEach(() => {
+ node = sass.parse('.foo').nodes[0] as Rule;
+ });
+
+ it('has matching selectorInterpolation', () =>
+ expect(node).toHaveInterpolation('selectorInterpolation', '.foo\n'));
+
+ it('has matching selector', () => expect(node.selector).toBe('.foo\n'));
+
+ it('has empty nodes', () => expect(node.nodes).toHaveLength(0));
+ });
+
+ describe('constructed manually', () => {
+ describeNode(
+ 'with an interpolation',
+ () =>
+ new Rule({
+ selectorInterpolation: new Interpolation({nodes: ['.foo ']}),
+ })
+ );
+
+ describeNode(
+ 'with a selector string',
+ () => new Rule({selector: '.foo '})
+ );
+ });
+
+ describe('constructed from ChildProps', () => {
+ describeNode('with an interpolation', () =>
+ utils.fromChildProps({
+ selectorInterpolation: new Interpolation({nodes: ['.foo ']}),
+ })
+ );
+
+ describeNode('with a selector string', () =>
+ utils.fromChildProps({selector: '.foo '})
+ );
+ });
+ });
+
+ describe('with a child', () => {
+ function describeNode(description: string, create: () => Rule): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has matching selectorInterpolation', () =>
+ expect(node).toHaveInterpolation('selectorInterpolation', '.foo '));
+
+ it('has matching selector', () => expect(node.selector).toBe('.foo '));
+
+ it('has a child node', () => {
+ expect(node.nodes).toHaveLength(1);
+ expect(node.nodes[0]).toBeInstanceOf(GenericAtRule);
+ expect(node.nodes[0]).toHaveProperty('name', 'bar');
+ });
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('.foo {@bar}').nodes[0] as Rule
+ );
+
+ describeNode(
+ 'parsed as CSS',
+ () => css.parse('.foo {@bar}').nodes[0] as Rule
+ );
+
+ describe('parsed as Sass', () => {
+ beforeEach(() => {
+ node = sass.parse('.foo\n @bar').nodes[0] as Rule;
+ });
+
+ it('has matching selectorInterpolation', () =>
+ expect(node).toHaveInterpolation('selectorInterpolation', '.foo\n'));
+
+ it('has matching selector', () => expect(node.selector).toBe('.foo\n'));
+
+ it('has a child node', () => {
+ expect(node.nodes).toHaveLength(1);
+ expect(node.nodes[0]).toBeInstanceOf(GenericAtRule);
+ expect(node.nodes[0]).toHaveProperty('name', 'bar');
+ });
+ });
+
+ describe('constructed manually', () => {
+ describeNode(
+ 'with an interpolation',
+ () =>
+ new Rule({
+ selectorInterpolation: new Interpolation({nodes: ['.foo ']}),
+ nodes: [{name: 'bar'}],
+ })
+ );
+
+ describeNode(
+ 'with a selector string',
+ () => new Rule({selector: '.foo ', nodes: [{name: 'bar'}]})
+ );
+ });
+
+ describe('constructed from ChildProps', () => {
+ describeNode('with an interpolation', () =>
+ utils.fromChildProps({
+ selectorInterpolation: new Interpolation({nodes: ['.foo ']}),
+ nodes: [{name: 'bar'}],
+ })
+ );
+
+ describeNode('with a selector string', () =>
+ utils.fromChildProps({selector: '.foo ', nodes: [{name: 'bar'}]})
+ );
+ });
+ });
+
+ describe('assigned a new selector', () => {
+ beforeEach(() => {
+ node = scss.parse('.foo {}').nodes[0] as Rule;
+ });
+
+ it("removes the old interpolation's parent", () => {
+ const oldSelector = node.selectorInterpolation!;
+ node.selectorInterpolation = '.bar';
+ expect(oldSelector.parent).toBeUndefined();
+ });
+
+ it("assigns the new interpolation's parent", () => {
+ const interpolation = new Interpolation({nodes: ['.bar']});
+ node.selectorInterpolation = interpolation;
+ expect(interpolation.parent).toBe(node);
+ });
+
+ it('assigns the interpolation explicitly', () => {
+ const interpolation = new Interpolation({nodes: ['.bar']});
+ node.selectorInterpolation = interpolation;
+ expect(node.selectorInterpolation).toBe(interpolation);
+ });
+
+ it('assigns the interpolation as a string', () => {
+ node.selectorInterpolation = '.bar';
+ expect(node).toHaveInterpolation('selectorInterpolation', '.bar');
+ });
+
+ it('assigns the interpolation as selector', () => {
+ node.selector = '.bar';
+ expect(node).toHaveInterpolation('selectorInterpolation', '.bar');
+ });
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ describe('with default raws', () => {
+ it('with no children', () =>
+ expect(new Rule({selector: '.foo'}).toString()).toBe('.foo {}'));
+
+ it('with a child', () =>
+ expect(
+ new Rule({selector: '.foo', nodes: [{selector: '.bar'}]}).toString()
+ ).toBe('.foo {\n .bar {}\n}'));
+ });
+
+ it('with between', () =>
+ expect(
+ new Rule({
+ selector: '.foo',
+ raws: {between: '/**/'},
+ }).toString()
+ ).toBe('.foo/**/{}'));
+
+ describe('with after', () => {
+ it('with no children', () =>
+ expect(
+ new Rule({selector: '.foo', raws: {after: '/**/'}}).toString()
+ ).toBe('.foo {/**/}'));
+
+ it('with a child', () =>
+ expect(
+ new Rule({
+ selector: '.foo',
+ nodes: [{selector: '.bar'}],
+ raws: {after: '/**/'},
+ }).toString()
+ ).toBe('.foo {\n .bar {}/**/}'));
+ });
+
+ it('with before', () =>
+ expect(
+ new Root({
+ nodes: [new Rule({selector: '.foo', raws: {before: '/**/'}})],
+ }).toString()
+ ).toBe('/**/.foo {}'));
+ });
+ });
+
+ describe('clone', () => {
+ let original: Rule;
+ beforeEach(() => {
+ original = scss.parse('.foo {@bar}').nodes[0] as Rule;
+ // TODO: remove this once raws are properly parsed
+ original.raws.between = ' ';
+ });
+
+ describe('with no overrides', () => {
+ let clone: Rule;
+ beforeEach(() => {
+ clone = original.clone();
+ });
+
+ describe('has the same properties:', () => {
+ it('selectorInterpolation', () =>
+ expect(clone).toHaveInterpolation('selectorInterpolation', '.foo '));
+
+ it('selector', () => expect(clone.selector).toBe('.foo '));
+
+ it('raws', () => expect(clone.raws).toEqual({between: ' '}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+
+ it('nodes', () => {
+ expect(clone.nodes).toHaveLength(1);
+ expect(clone.nodes[0]).toBeInstanceOf(GenericAtRule);
+ expect(clone.nodes[0]).toHaveProperty('name', 'bar');
+ });
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of [
+ 'selectorInterpolation',
+ 'raws',
+ 'nodes',
+ ] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+
+ describe('sets parent for', () => {
+ it('nodes', () => expect(clone.nodes[0].parent).toBe(clone));
+ });
+ });
+
+ describe('overrides', () => {
+ describe('selector', () => {
+ describe('defined', () => {
+ let clone: Rule;
+ beforeEach(() => {
+ clone = original.clone({selector: 'qux'});
+ });
+
+ it('changes selector', () => expect(clone.selector).toBe('qux'));
+
+ it('changes selectorInterpolation', () =>
+ expect(clone).toHaveInterpolation('selectorInterpolation', 'qux'));
+ });
+
+ describe('undefined', () => {
+ let clone: Rule;
+ beforeEach(() => {
+ clone = original.clone({selector: undefined});
+ });
+
+ it('preserves selector', () => expect(clone.selector).toBe('.foo '));
+
+ it('preserves selectorInterpolation', () =>
+ expect(clone).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.foo '
+ ));
+ });
+ });
+
+ describe('selectorInterpolation', () => {
+ describe('defined', () => {
+ let clone: Rule;
+ beforeEach(() => {
+ clone = original.clone({
+ selectorInterpolation: new Interpolation({nodes: ['.baz']}),
+ });
+ });
+
+ it('changes selector', () => expect(clone.selector).toBe('.baz'));
+
+ it('changes selectorInterpolation', () =>
+ expect(clone).toHaveInterpolation('selectorInterpolation', '.baz'));
+ });
+
+ describe('undefined', () => {
+ let clone: Rule;
+ beforeEach(() => {
+ clone = original.clone({selectorInterpolation: undefined});
+ });
+
+ it('preserves selector', () => expect(clone.selector).toBe('.foo '));
+
+ it('preserves selectorInterpolation', () =>
+ expect(clone).toHaveInterpolation(
+ 'selectorInterpolation',
+ '.foo '
+ ));
+ });
+ });
+
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {before: ' '}}).raws).toEqual({
+ before: ' ',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ between: ' ',
+ }));
+ });
+ });
+ });
+
+ describe('toJSON', () => {
+ it('with empty children', () =>
+ expect(scss.parse('.foo {}').nodes[0]).toMatchSnapshot());
+
+ it('with a child', () =>
+ expect(scss.parse('.foo {@bar}').nodes[0]).toMatchSnapshot());
+ });
+});
diff --git a/pkg/sass-parser/lib/src/statement/rule.ts b/pkg/sass-parser/lib/src/statement/rule.ts
new file mode 100644
index 000000000..087a99ba1
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/rule.ts
@@ -0,0 +1,141 @@
+// 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 {RuleRaws as PostcssRuleRaws} from 'postcss/lib/rule';
+
+import {Interpolation} from '../interpolation';
+import {LazySource} from '../lazy-source';
+import type * as sassInternal from '../sass-internal';
+import * as utils from '../utils';
+import {
+ ChildNode,
+ ContainerProps,
+ NewNode,
+ Statement,
+ StatementWithChildren,
+ appendInternalChildren,
+ normalize,
+} from '.';
+import {interceptIsClean} from './intercept-is-clean';
+import {_Rule} from './rule-internal';
+import * as sassParser from '../..';
+
+/**
+ * The set of raws supported by a style rule.
+ *
+ * Sass doesn't support PostCSS's `params` raws, since the selector is lexed and
+ * made directly available to the caller.
+ *
+ * @category Statement
+ */
+export type RuleRaws = Omit;
+
+/**
+ * The initializer properties for {@link Rule}.
+ *
+ * @category Statement
+ */
+export type RuleProps = ContainerProps & {raws?: RuleRaws} & (
+ | {selectorInterpolation: Interpolation | string}
+ | {selector: string}
+ | {selectors: string[]}
+ );
+
+/**
+ * A style rule. Extends [`postcss.Rule`].
+ *
+ * [`postcss.Rule`]: https://postcss.org/api/#rule
+ *
+ * @category Statement
+ */
+export class Rule extends _Rule implements Statement {
+ readonly sassType = 'rule' as const;
+ declare parent: StatementWithChildren | undefined;
+ declare raws: RuleRaws;
+
+ get selector(): string {
+ return this.selectorInterpolation.toString();
+ }
+ set selector(value: string) {
+ this.selectorInterpolation = value;
+ }
+
+ /** The interpolation that represents this rule's selector. */
+ get selectorInterpolation(): Interpolation {
+ return this._selectorInterpolation!;
+ }
+ set selectorInterpolation(selectorInterpolation: Interpolation | string) {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ if (this._selectorInterpolation) {
+ this._selectorInterpolation.parent = undefined;
+ }
+ if (typeof selectorInterpolation === 'string') {
+ selectorInterpolation = new Interpolation({
+ nodes: [selectorInterpolation],
+ });
+ }
+ selectorInterpolation.parent = this;
+ this._selectorInterpolation = selectorInterpolation;
+ }
+ private _selectorInterpolation?: Interpolation;
+
+ constructor(defaults: RuleProps);
+ constructor(_: undefined, inner: sassInternal.StyleRule);
+ /** @hidden */
+ constructor(defaults?: RuleProps, inner?: sassInternal.StyleRule) {
+ // PostCSS claims that it requires either selector or selectors, but we
+ // define the former as a getter instead.
+ super(defaults as postcss.RuleProps);
+ if (inner) {
+ this.source = new LazySource(inner);
+ this.selectorInterpolation = new Interpolation(undefined, inner.selector);
+ appendInternalChildren(this, inner.children);
+ }
+ }
+
+ // TODO: Once we make selector parsing available to JS, use it to override
+ // selectors() and to provide access to parsed selectors if selector is plain
+ // text.
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(
+ this,
+ overrides,
+ ['nodes', 'raws', 'selectorInterpolation'],
+ ['selector', 'selectors']
+ );
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(
+ this,
+ ['selector', 'selectorInterpolation', 'nodes'],
+ inputs
+ );
+ }
+
+ /** @hidden */
+ toString(
+ stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
+ .stringify
+ ): string {
+ return super.toString(stringifier);
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [this.selectorInterpolation];
+ }
+
+ /** @hidden */
+ normalize(node: NewNode, sample?: postcss.Node): ChildNode[] {
+ return normalize(this, node, sample);
+ }
+}
+
+interceptIsClean(Rule);
diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts
new file mode 100644
index 000000000..c732b8dde
--- /dev/null
+++ b/pkg/sass-parser/lib/src/stringifier.ts
@@ -0,0 +1,88 @@
+// 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.
+
+// Portions of this source file are adapted from the PostCSS codebase under the
+// terms of the following license:
+//
+// The MIT License (MIT)
+//
+// Copyright 2013 Andrey Sitnik
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of
+// this software and associated documentation files (the "Software"), to deal in
+// the Software without restriction, including without limitation the rights to
+// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+// the Software, and to permit persons to whom the Software is furnished to do so,
+// subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+import * as postcss from 'postcss';
+
+import {AnyStatement} from './statement';
+import {GenericAtRule} from './statement/generic-at-rule';
+import {Rule} from './statement/rule';
+
+const PostCssStringifier = require('postcss/lib/stringifier');
+
+/**
+ * A visitor that stringifies Sass statements.
+ *
+ * Expression-level nodes are handled differently because they don't need to
+ * integrate into PostCSS's source map infratructure.
+ */
+export class Stringifier extends PostCssStringifier {
+ constructor(builder: postcss.Builder) {
+ super(builder);
+ }
+
+ /** Converts `node` into a string by calling {@link this.builder}. */
+ stringify(node: postcss.AnyNode, semicolon: boolean): void {
+ if (!('sassType' in node)) {
+ postcss.stringify(node, this.builder);
+ return;
+ }
+
+ const statement = node as AnyStatement;
+ if (!this[statement.sassType]) {
+ throw new Error(
+ `Unknown AST node type ${statement.sassType}. ` +
+ 'Maybe you need to change PostCSS stringifier.'
+ );
+ }
+ (
+ this[statement.sassType] as (
+ node: AnyStatement,
+ semicolon: boolean
+ ) => void
+ )(statement, semicolon);
+ }
+
+ private atrule(node: GenericAtRule, semicolon: boolean): void {
+ const start =
+ `@${node.nameInterpolation}` +
+ (node.raws.afterName ?? (node.paramsInterpolation ? ' ' : '')) +
+ (node.paramsInterpolation ?? '');
+ if (node.nodes) {
+ this.block(node, start);
+ } else {
+ this.builder(
+ start + (node.raws.between ?? '') + (semicolon ? ';' : ''),
+ node
+ );
+ }
+ }
+
+ private rule(node: Rule): void {
+ this.block(node, node.selectorInterpolation.toString());
+ }
+}
diff --git a/pkg/sass-parser/lib/src/utils.ts b/pkg/sass-parser/lib/src/utils.ts
new file mode 100644
index 000000000..d041c27fd
--- /dev/null
+++ b/pkg/sass-parser/lib/src/utils.ts
@@ -0,0 +1,193 @@
+// 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 {Node} from './node';
+
+/**
+ * A type that matches any constructor for {@link T}. From
+ * https://www.typescriptlang.org/docs/handbook/mixins.html.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type Constructor = new (...args: any[]) => T;
+
+/**
+ * An explicit field description passed to `cloneNode` that describes in detail
+ * how to clone it.
+ */
+interface ExplicitClonableField {
+ /** The field's name. */
+ name: Name;
+
+ /**
+ * Whether the field can be set to an explicit undefined value which means
+ * something different than an absent field.
+ */
+ explicitUndefined?: boolean;
+}
+
+/** The type of field names that can be passed into `cloneNode`. */
+type ClonableField = Name | ExplicitClonableField;
+
+/** Makes a {@link ClonableField} explicit. */
+function parseClonableField(
+ field: ClonableField
+): ExplicitClonableField {
+ return typeof field === 'string' ? {name: field} : field;
+}
+
+/**
+ * Creates a copy of {@link node} by passing all the properties in {@link
+ * constructorFields} as an object to its constructor.
+ *
+ * If {@link overrides} is passed, it overrides any existing constructor field
+ * values. It's also used to assign {@link assignedFields} after the cloned
+ * object has been constructed.
+ */
+export function cloneNode>(
+ node: T,
+ overrides: Record | undefined,
+ constructorFields: ClonableField[],
+ assignedFields?: ClonableField[]
+): T {
+ // We have to do these casts because the actual `...Prop` types that get
+ // passed in and used for the constructor aren't actually subtypes of
+ // `Partial`. They use `never` types to ensure that various properties are
+ // mutually exclusive, which is not compatible.
+ const typedOverrides = overrides as Partial | undefined;
+ const constructorFn = node.constructor as new (defaults: Partial) => T;
+
+ const constructorParams: Partial = {};
+ for (const field of constructorFields) {
+ const {name, explicitUndefined} = parseClonableField(field);
+ let value: T[keyof T & string] | undefined;
+ if (
+ typedOverrides &&
+ (explicitUndefined
+ ? Object.hasOwn(typedOverrides, name)
+ : typedOverrides[name] !== undefined)
+ ) {
+ value = typedOverrides[name];
+ } else {
+ value = maybeClone(node[name]);
+ }
+ if (value !== undefined) constructorParams[name] = value;
+ }
+ const cloned = new constructorFn(constructorParams);
+
+ if (typedOverrides && assignedFields) {
+ for (const field of assignedFields) {
+ const {name, explicitUndefined} = parseClonableField(field);
+ if (
+ explicitUndefined
+ ? Object.hasOwn(typedOverrides, name)
+ : typedOverrides[name]
+ ) {
+ // This isn't actually guaranteed to be non-null, but TypeScript
+ // (correctly) complains that we could be passing an undefined value to
+ // a field that doesn't allow undefined. We don't have a good way of
+ // forbidding that while still allowing users to override values that do
+ // explicitly allow undefined, though.
+ cloned[name] = typedOverrides[name]!;
+ }
+ }
+ }
+
+ cloned.source = node.source;
+ return cloned;
+}
+
+/**
+ * If {@link value} is a Sass node, a record, or an array, clones it and returns
+ * the clone. Otherwise, returns it as-is.
+ */
+function maybeClone(value: T): T {
+ if (Array.isArray(value)) return value.map(maybeClone) as T;
+ if (typeof value !== 'object' || value === null) return value;
+ // The only records we care about are raws, which only contain primitives and
+ // arrays of primitives, so structued cloning is safe.
+ if (value.constructor === Object) return structuredClone(value);
+ if (value instanceof postcss.Node) return value.clone() as T;
+ return value;
+}
+
+/**
+ * Converts {@link node} into a JSON-safe object, with the given {@link fields}
+ * included.
+ *
+ * This always includes the `type`, `sassType`, `raws`, and `source` fields if
+ * set. It converts multiple references to the same source input object into
+ * indexes into a top-level list.
+ */
+export function toJSON(
+ node: T,
+ fields: (keyof T & string)[],
+ inputs?: Map
+): object {
+ // Only include the inputs field at the top level.
+ const includeInputs = !inputs;
+ inputs ??= new Map();
+ let inputIndex = inputs.size;
+
+ const result: Record = {};
+ if ('type' in node) result.type = (node as {type: string}).type;
+
+ fields = ['sassType', 'raws', ...fields];
+ for (const field of fields) {
+ const value = node[field];
+ if (value !== undefined) result[field] = toJsonField(field, value, inputs);
+ }
+
+ if (node.source) {
+ let inputId = inputs.get(node.source.input);
+ if (inputId === undefined) {
+ inputId = inputIndex++;
+ inputs.set(node.source.input, inputId);
+ }
+
+ result.source = {
+ start: node.source.start,
+ end: node.source.end,
+ inputId,
+ };
+ }
+
+ if (includeInputs) {
+ result.inputs = [...inputs.keys()].map(input => input.toJSON());
+ }
+ return result;
+}
+
+/**
+ * Converts a single field with name {@link field} and value {@link value} to a
+ * JSON-safe object.
+ *
+ * The {@link inputs} map works the same as it does in {@link toJSON}.
+ */
+function toJsonField(
+ field: string,
+ value: unknown,
+ inputs: Map
+): 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 ('toJSON' in value) {
+ if ('sassType' in value) {
+ return (
+ value as {
+ toJSON: (field: string, inputs: Map) => object;
+ }
+ ).toJSON('', inputs);
+ } else {
+ return (value as {toJSON: (field: string) => object}).toJSON(field);
+ }
+ } else {
+ return value;
+ }
+}
diff --git a/pkg/sass-parser/package.json b/pkg/sass-parser/package.json
new file mode 100644
index 000000000..83b93ee91
--- /dev/null
+++ b/pkg/sass-parser/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "sass-parser",
+ "version": "0.2.0",
+ "description": "A PostCSS-compatible wrapper of the official Sass parser",
+ "repository": "sass/sass",
+ "author": "Google Inc.",
+ "license": "MIT",
+ "exports": {
+ "types": "./dist/types/index.d.ts",
+ "default": "./dist/lib/index.js"
+ },
+ "main": "dist/lib/index.js",
+ "types": "dist/types/index.d.ts",
+ "files": [
+ "dist/**/*.{js,d.ts}"
+ ],
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "scripts": {
+ "init": "ts-node ./tool/init.ts",
+ "check": "npm-run-all check:gts check:tsc",
+ "check:gts": "gts check",
+ "check:tsc": "tsc --noEmit",
+ "clean": "gts clean",
+ "compile": "tsc -p tsconfig.build.json && copyfiles -u 1 \"lib/**/*.{js,d.ts}\" dist/lib/",
+ "prepack": "copyfiles -u 2 ../../LICENSE .",
+ "postpack": "rimraf LICENSE",
+ "typedoc": "npx typedoc --treatWarningsAsErrors",
+ "fix": "gts fix",
+ "test": "jest"
+ },
+ "dependencies": {
+ "postcss": ">=8.4.41 <8.5.0",
+ "sass": "1.77.8"
+ },
+ "devDependencies": {
+ "@types/jest": "^29.5.12",
+ "copyfiles": "^2.4.1",
+ "expect": "^29.7.0",
+ "gts": "^5.0.0",
+ "jest": "^29.4.1",
+ "jest-extended": "^4.0.2",
+ "npm-run-all": "^4.1.5",
+ "rimraf": "^6.0.1",
+ "ts-jest": "^29.0.5",
+ "ts-node": "^10.2.1",
+ "typedoc": "^0.26.5",
+ "typescript": "^5.0.2"
+ }
+}
diff --git a/pkg/sass-parser/test/setup.ts b/pkg/sass-parser/test/setup.ts
new file mode 100644
index 000000000..35b17de62
--- /dev/null
+++ b/pkg/sass-parser/test/setup.ts
@@ -0,0 +1,285 @@
+// 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 type {ExpectationResult, MatcherContext} from 'expect';
+import * as p from 'path';
+import * as postcss from 'postcss';
+// Unclear why eslint considers this extraneous
+// eslint-disable-next-line n/no-extraneous-import
+import type * as pretty from 'pretty-format';
+import 'jest-extended';
+
+import {Interpolation, StringExpression} from '../lib';
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace jest {
+ interface AsymmetricMatchers {
+ /**
+ * Asserts that the object being matched has a property named {@link
+ * property} whose value is an {@link Interpolation}, that that
+ * interpolation's value is {@link value}, and that the interpolation's
+ * parent is the object being tested.
+ */
+ toHaveInterpolation(property: string, value: string): void;
+
+ /**
+ * Asserts that the object being matched has a property named {@link
+ * property} whose value is a {@link StringExpression}, that that string's
+ * value is {@link value}, and that the string's parent is the object
+ * being tested.
+ *
+ * If {@link property} is a number, it's treated as an index into the
+ * `nodes` property of the object being matched.
+ */
+ toHaveStringExpression(property: string | number, value: string): void;
+ }
+
+ interface Matchers {
+ toHaveInterpolation(property: string, value: string): R;
+ toHaveStringExpression(property: string | number, value: string): R;
+ }
+ }
+}
+
+function toHaveInterpolation(
+ this: MatcherContext,
+ actual: unknown,
+ property: unknown,
+ value: unknown
+): ExpectationResult {
+ if (typeof property !== 'string') {
+ throw new TypeError(`Property ${property} must be a string.`);
+ } else if (typeof value !== 'string') {
+ throw new TypeError(`Value ${value} must be a string.`);
+ }
+
+ if (typeof actual !== 'object' || !actual || !(property in actual)) {
+ return {
+ message: () =>
+ `expected ${this.utils.printReceived(
+ actual
+ )} to have a property ${this.utils.printExpected(property)}`,
+ pass: false,
+ };
+ }
+
+ const actualValue = (actual as Record)[property];
+ const message = (): string =>
+ `expected (${this.utils.printReceived(
+ actual
+ )}).${property} ${this.utils.printReceived(
+ actualValue
+ )} to be an Interpolation with value ${this.utils.printExpected(value)}`;
+
+ if (
+ !(actualValue instanceof Interpolation) ||
+ actualValue.asPlain !== value
+ ) {
+ return {
+ message,
+ pass: false,
+ };
+ }
+
+ if (actualValue.parent !== actual) {
+ return {
+ message: () =>
+ `expected (${this.utils.printReceived(
+ actual
+ )}).${property} ${this.utils.printReceived(
+ actualValue
+ )} to have the correct parent`,
+ pass: false,
+ };
+ }
+
+ return {message, pass: true};
+}
+
+expect.extend({toHaveInterpolation});
+
+function toHaveStringExpression(
+ this: MatcherContext,
+ actual: unknown,
+ propertyOrIndex: unknown,
+ value: unknown
+): ExpectationResult {
+ if (
+ typeof propertyOrIndex !== 'string' &&
+ typeof propertyOrIndex !== 'number'
+ ) {
+ throw new TypeError(
+ `Property ${propertyOrIndex} must be a string or number.`
+ );
+ } else if (typeof value !== 'string') {
+ throw new TypeError(`Value ${value} must be a string.`);
+ }
+
+ let index: number | null = null;
+ let property: string;
+ if (typeof propertyOrIndex === 'number') {
+ index = propertyOrIndex;
+ property = 'nodes';
+ } else {
+ property = propertyOrIndex;
+ }
+
+ if (typeof actual !== 'object' || !actual || !(property in actual)) {
+ return {
+ message: () =>
+ `expected ${this.utils.printReceived(
+ actual
+ )} to have a property ${this.utils.printExpected(property)}`,
+ pass: false,
+ };
+ }
+
+ let actualValue = (actual as Record)[property];
+ if (index !== null) actualValue = (actualValue as unknown[])[index];
+
+ const message = (): string => {
+ let message = `expected (${this.utils.printReceived(actual)}).${property}`;
+ if (index !== null) message += `[${index}]`;
+
+ return (
+ message +
+ ` ${this.utils.printReceived(
+ actualValue
+ )} to be a StringExpression with value ${this.utils.printExpected(value)}`
+ );
+ };
+
+ if (
+ !(actualValue instanceof StringExpression) ||
+ actualValue.text.asPlain !== value
+ ) {
+ return {
+ message,
+ pass: false,
+ };
+ }
+
+ if (actualValue.parent !== actual) {
+ return {
+ message: () =>
+ `expected (${this.utils.printReceived(
+ actual
+ )}).${property} ${this.utils.printReceived(
+ actualValue
+ )} to have the correct parent`,
+ pass: false,
+ };
+ }
+
+ return {message, pass: true};
+}
+
+expect.extend({toHaveStringExpression});
+
+// Serialize nodes using toJSON(), but also updating them to avoid run- or
+// machine-specific information in the inputs and to make sources and nested
+// nodes more concise.
+expect.addSnapshotSerializer({
+ test(value: unknown): boolean {
+ return value instanceof postcss.Node;
+ },
+
+ serialize(
+ value: postcss.Node,
+ config: pretty.Config,
+ indentation: string,
+ depth: number,
+ refs: pretty.Refs,
+ printer: pretty.Printer
+ ): string {
+ if (depth !== 0) return `<${value}>`;
+
+ const json = value.toJSON() as Record;
+ for (const input of (json as {inputs: Record[]}).inputs) {
+ if ('id' in input) {
+ input.id = input.id.replace(/ [^ >]+>$/, ' _____>');
+ }
+ if ('file' in input) {
+ input.file = p
+ .relative(process.cwd(), input.file)
+ .replaceAll(p.sep, p.posix.sep);
+ }
+ }
+
+ // Convert JSON-ified Sass nodes back into their original forms so that they
+ // can be serialized tersely in snapshots.
+ for (const [key, jsonValue] of Object.entries(json)) {
+ if (!jsonValue) continue;
+ if (Array.isArray(jsonValue)) {
+ const originalArray = value[key as keyof typeof value];
+ if (!Array.isArray(originalArray)) continue;
+
+ for (let i = 0; i < jsonValue.length; i++) {
+ const element = jsonValue[i];
+ if (element && typeof element === 'object' && 'sassType' in element) {
+ jsonValue[i] = originalArray[i];
+ }
+ }
+ } else if (
+ jsonValue &&
+ typeof jsonValue === 'object' &&
+ 'sassType' in jsonValue
+ ) {
+ json[key] = value[key as keyof typeof value];
+ }
+ }
+
+ return printer(json, config, indentation, depth, refs, true);
+ },
+});
+
+/** The JSON serialization of {@link postcss.Range}. */
+interface JsonRange {
+ start: JsonPosition;
+ end: JsonPosition;
+ inputId: number;
+}
+
+/** The JSON serialization of {@link postcss.Position}. */
+interface JsonPosition {
+ line: number;
+ column: number;
+ offset: number;
+}
+
+// Serialize source entries as terse strings because otherwise they take up a
+// large amount of room for a small amount of information.
+expect.addSnapshotSerializer({
+ test(value: unknown): boolean {
+ return (
+ !!value &&
+ typeof value === 'object' &&
+ 'inputId' in value &&
+ 'start' in value &&
+ 'end' in value
+ );
+ },
+
+ serialize(value: JsonRange): string {
+ return (
+ `<${tersePosition(value.start)}-${tersePosition(value.end)} in ` +
+ `${value.inputId}>`
+ );
+ },
+});
+
+/** Converts a {@link JsonPosition} into a terse string representation. */
+function tersePosition(position: JsonPosition): string {
+ if (position.offset !== position.column - 1) {
+ throw new Error(
+ 'Expected offset to be 1 less than column. Column is ' +
+ `${position.column} and offset is ${position.offset}.`
+ );
+ }
+
+ return `${position.line}:${position.column}`;
+}
+
+export {};
diff --git a/pkg/sass-parser/test/utils.ts b/pkg/sass-parser/test/utils.ts
new file mode 100644
index 000000000..1c667d62c
--- /dev/null
+++ b/pkg/sass-parser/test/utils.ts
@@ -0,0 +1,35 @@
+// 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 {
+ ChildNode,
+ ChildProps,
+ Expression,
+ ExpressionProps,
+ GenericAtRule,
+ Interpolation,
+ Root,
+ scss,
+} from '../lib';
+
+/** Parses a Sass expression from {@link text}. */
+export function parseExpression(text: string): T {
+ const interpolation = (scss.parse(`@#{${text}}`).nodes[0] as GenericAtRule)
+ .nameInterpolation;
+ const expression = interpolation.nodes[0] as T;
+ interpolation.removeChild(expression);
+ return expression;
+}
+
+/** Constructs a new node from {@link props} as in child node injection. */
+export function fromChildProps(props: ChildProps): T {
+ return new Root({nodes: [props]}).nodes[0] as T;
+}
+
+/** Constructs a new expression from {@link props}. */
+export function fromExpressionProps(
+ props: ExpressionProps
+): T {
+ return new Interpolation({nodes: [props]}).nodes[0] as T;
+}
diff --git a/pkg/sass-parser/tsconfig.build.json b/pkg/sass-parser/tsconfig.build.json
new file mode 100644
index 000000000..78a5d1ef7
--- /dev/null
+++ b/pkg/sass-parser/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["*.ts", "**/*.test.ts", "test/**/*.ts"]
+}
diff --git a/pkg/sass-parser/tsconfig.json b/pkg/sass-parser/tsconfig.json
new file mode 100644
index 000000000..f0cf2e4c8
--- /dev/null
+++ b/pkg/sass-parser/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "./node_modules/gts/tsconfig-google.json",
+ "compilerOptions": {
+ "lib": ["es2022"],
+ "allowJs": true,
+ "outDir": "dist",
+ "resolveJsonModule": true,
+ "rootDir": ".",
+ "useUnknownInCatchVariables": false,
+ "declaration": true
+ },
+ "include": [
+ "*.ts",
+ "lib/**/*.ts",
+ "tool/**/*.ts",
+ "test/**/*.ts"
+ ]
+}
diff --git a/pkg/sass-parser/typedoc.config.js b/pkg/sass-parser/typedoc.config.js
new file mode 100644
index 000000000..9395d03c8
--- /dev/null
+++ b/pkg/sass-parser/typedoc.config.js
@@ -0,0 +1,16 @@
+/** @type {import('typedoc').TypeDocOptions} */
+module.exports = {
+ entryPoints: ["./lib/index.ts"],
+ highlightLanguages: ["cmd", "dart", "dockerfile", "js", "ts", "sh", "html"],
+ out: "doc",
+ navigation: {
+ includeCategories: true,
+ },
+ hideParameterTypesInTitle: false,
+ categorizeByGroup: false,
+ categoryOrder: [
+ "Statement",
+ "Expression",
+ "Other",
+ ]
+};
diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md
index cbedd7005..8284c79ae 100644
--- a/pkg/sass_api/CHANGELOG.md
+++ b/pkg/sass_api/CHANGELOG.md
@@ -1,6 +1,6 @@
-## 10.5.0
+## 11.0.0
-* No user-visible changes.
+* Remove the `CallableDeclaration()` constructor.
## 10.4.8
diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml
index 8ae210dbc..9912eecb6 100644
--- a/pkg/sass_api/pubspec.yaml
+++ b/pkg/sass_api/pubspec.yaml
@@ -2,7 +2,7 @@ name: sass_api
# Note: Every time we add a new Sass AST node, we need to bump the *major*
# version because it's a breaking change for anyone who's implementing the
# visitor interface(s).
-version: 10.5.0-dev
+version: 11.0.0-dev
description: Additional APIs for Dart Sass.
homepage: https://github.com/sass/dart-sass
diff --git a/pubspec.yaml b/pubspec.yaml
index bb851e64e..51ea914b7 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -32,7 +32,7 @@ dependencies:
stack_trace: ^1.10.0
stream_channel: ^2.1.0
stream_transform: ^2.0.0
- string_scanner: ^1.1.0
+ string_scanner: ^1.3.0
term_glyph: ^1.2.0
typed_data: ^1.1.0
watcher: ^1.0.0
diff --git a/test/double_check_test.dart b/test/double_check_test.dart
index 6b8f67e96..b4490e586 100644
--- a/test/double_check_test.dart
+++ b/test/double_check_test.dart
@@ -46,98 +46,136 @@ void main() {
// newline normalization issues.
testOn: "!windows");
- for (var package in [
- ".",
- ...Directory("pkg").listSync().map((entry) => entry.path)
- ]) {
+ for (var package in [".", "pkg/sass_api"]) {
group("in ${p.relative(package)}", () {
test("pubspec version matches CHANGELOG version", () {
- var firstLine = const LineSplitter()
- .convert(File("$package/CHANGELOG.md").readAsStringSync())
- .first;
- expect(firstLine, startsWith("## "));
- var changelogVersion = Version.parse(firstLine.substring(3));
-
var pubspec = Pubspec.parse(
File("$package/pubspec.yaml").readAsStringSync(),
sourceUrl: p.toUri("$package/pubspec.yaml"));
- expect(
- pubspec.version!.toString(),
- anyOf(
- equals(changelogVersion.toString()),
- changelogVersion.isPreRelease
- ? equals("${changelogVersion.nextPatch}-dev")
- : equals("$changelogVersion-dev")));
+ expect(pubspec.version!.toString(),
+ matchesChangelogVersion(_changelogVersion(package)));
});
});
}
- for (var package in Directory("pkg").listSync().map((entry) => entry.path)) {
- group("in pkg/${p.basename(package)}", () {
- late Pubspec sassPubspec;
- late Pubspec pkgPubspec;
- setUpAll(() {
- sassPubspec = Pubspec.parse(File("pubspec.yaml").readAsStringSync(),
- sourceUrl: Uri.parse("pubspec.yaml"));
- pkgPubspec = Pubspec.parse(
- File("$package/pubspec.yaml").readAsStringSync(),
- sourceUrl: p.toUri("$package/pubspec.yaml"));
- });
+ group("in pkg/sass_api", () {
+ late Pubspec sassPubspec;
+ late Pubspec pkgPubspec;
+ setUpAll(() {
+ sassPubspec = Pubspec.parse(File("pubspec.yaml").readAsStringSync(),
+ sourceUrl: Uri.parse("pubspec.yaml"));
+ pkgPubspec = Pubspec.parse(
+ File("pkg/sass_api/pubspec.yaml").readAsStringSync(),
+ sourceUrl: p.toUri("pkg/sass_api/pubspec.yaml"));
+ });
- test("depends on the current sass version", () {
- if (_isDevVersion(sassPubspec.version!)) return;
+ test("depends on the current sass version", () {
+ if (_isDevVersion(sassPubspec.version!)) return;
- expect(pkgPubspec.dependencies, contains("sass"));
- var dependency = pkgPubspec.dependencies["sass"]!;
- expect(dependency, isA());
- expect((dependency as HostedDependency).version,
- equals(sassPubspec.version));
- });
+ expect(pkgPubspec.dependencies, contains("sass"));
+ var dependency = pkgPubspec.dependencies["sass"]!;
+ expect(dependency, isA());
+ expect((dependency as HostedDependency).version,
+ equals(sassPubspec.version));
+ });
- test("increments along with the sass version", () {
- var sassVersion = sassPubspec.version!;
- if (_isDevVersion(sassVersion)) return;
-
- var pkgVersion = pkgPubspec.version!;
- expect(_isDevVersion(pkgVersion), isFalse,
- reason: "sass $sassVersion isn't a dev version but "
- "${pkgPubspec.name} $pkgVersion is");
-
- if (sassVersion.isPreRelease) {
- expect(pkgVersion.isPreRelease, isTrue,
- reason: "sass $sassVersion is a pre-release version but "
- "${pkgPubspec.name} $pkgVersion isn't");
- }
-
- // If only sass's patch version was incremented, there's not a good way
- // to tell whether the sub-package's version was incremented as well
- // because we don't have access to the prior version.
- if (sassVersion.patch != 0) return;
-
- if (sassVersion.minor != 0) {
- expect(pkgVersion.patch, equals(0),
- reason: "sass minor version was incremented, ${pkgPubspec.name} "
- "must increment at least its minor version");
- } else {
- expect(pkgVersion.minor, equals(0),
- reason: "sass major version was incremented, ${pkgPubspec.name} "
- "must increment at its major version as well");
- }
- });
+ test(
+ "increments along with the sass version",
+ () => _checkVersionIncrementsAlong(
+ 'sass_api', sassPubspec, pkgPubspec.version!));
- test("matches SDK version", () {
- expect(pkgPubspec.environment!["sdk"],
- equals(sassPubspec.environment!["sdk"]));
- });
+ test("matches SDK version", () {
+ expect(pkgPubspec.environment!["sdk"],
+ equals(sassPubspec.environment!["sdk"]));
+ });
- test("matches dartdoc version", () {
- expect(sassPubspec.devDependencies["dartdoc"],
- equals(pkgPubspec.devDependencies["dartdoc"]));
- });
+ test("matches dartdoc version", () {
+ expect(sassPubspec.devDependencies["dartdoc"],
+ equals(pkgPubspec.devDependencies["dartdoc"]));
});
- }
+ });
+
+ group("in pkg/sass-parser", () {
+ late Pubspec sassPubspec;
+ late Map packageJson;
+ setUpAll(() {
+ sassPubspec = Pubspec.parse(File("pubspec.yaml").readAsStringSync(),
+ sourceUrl: Uri.parse("pubspec.yaml"));
+ packageJson =
+ json.decode(File("pkg/sass-parser/package.json").readAsStringSync())
+ as Map;
+ });
+
+ test(
+ "package.json version matches CHANGELOG version",
+ () => expect(packageJson["version"].toString(),
+ matchesChangelogVersion(_changelogVersion("pkg/sass-parser"))));
+
+ test("depends on the current sass version", () {
+ if (_isDevVersion(sassPubspec.version!)) return;
+
+ var dependencies = packageJson["dependencies"] as Map;
+ expect(
+ dependencies, containsPair("sass", sassPubspec.version.toString()));
+ });
+
+ test(
+ "increments along with the sass version",
+ () => _checkVersionIncrementsAlong('sass-parser', sassPubspec,
+ Version.parse(packageJson["version"] as String)));
+ });
}
/// Returns whether [version] is a `-dev` version.
bool _isDevVersion(Version version) =>
version.preRelease.length == 1 && version.preRelease.first == 'dev';
+
+/// Returns the most recent version in the CHANGELOG for [package].
+Version _changelogVersion(String package) {
+ var firstLine = const LineSplitter()
+ .convert(File("$package/CHANGELOG.md").readAsStringSync())
+ .first;
+ expect(firstLine, startsWith("## "));
+ return Version.parse(firstLine.substring(3));
+}
+
+/// Returns a [Matcher] that matches any valid variant of the CHANGELOG version
+/// [version] that the package itself can have.
+Matcher matchesChangelogVersion(Version version) => anyOf(
+ equals(version.toString()),
+ version.isPreRelease
+ ? equals("${version.nextPatch}-dev")
+ : equals("$version-dev"));
+
+/// Verifies that [pkgVersion] loks like it was incremented when the version of
+/// the main Sass version was as well.
+void _checkVersionIncrementsAlong(
+ String pkgName, Pubspec sassPubspec, Version pkgVersion) {
+ var sassVersion = sassPubspec.version!;
+ if (_isDevVersion(sassVersion)) return;
+
+ expect(_isDevVersion(pkgVersion), isFalse,
+ reason: "sass $sassVersion isn't a dev version but $pkgName $pkgVersion "
+ "is");
+
+ if (sassVersion.isPreRelease) {
+ expect(pkgVersion.isPreRelease, isTrue,
+ reason: "sass $sassVersion is a pre-release version but $pkgName "
+ "$pkgVersion isn't");
+ }
+
+ // If only sass's patch version was incremented, there's not a good way
+ // to tell whether the sub-package's version was incremented as well
+ // because we don't have access to the prior version.
+ if (sassVersion.patch != 0) return;
+
+ if (sassVersion.minor != 0) {
+ expect(pkgVersion.patch, equals(0),
+ reason: "sass minor version was incremented, $pkgName must increment "
+ "at least its minor version");
+ } else {
+ expect(pkgVersion.minor, equals(0),
+ reason: "sass major version was incremented, $pkgName must increment "
+ "at its major version as well");
+ }
+}
diff --git a/tool/grind.dart b/tool/grind.dart
index 5f71995f5..8e95575ed 100644
--- a/tool/grind.dart
+++ b/tool/grind.dart
@@ -20,7 +20,7 @@ export 'grind/benchmark.dart';
export 'grind/double_check.dart';
export 'grind/frameworks.dart';
export 'grind/generate_deprecations.dart';
-export 'grind/subpackages.dart';
+export 'grind/sass_api.dart';
export 'grind/synchronize.dart';
export 'grind/utils.dart';
@@ -92,6 +92,7 @@ void main(List args) {
'NodePackageImporter',
'deprecations',
'Version',
+ 'parser_',
};
pkg.githubReleaseNotes.fn = () =>
diff --git a/tool/grind/sass_api.dart b/tool/grind/sass_api.dart
new file mode 100644
index 000000000..a45a406e8
--- /dev/null
+++ b/tool/grind/sass_api.dart
@@ -0,0 +1,80 @@
+// Copyright 2021 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 'dart:io';
+import 'dart:convert';
+
+import 'package:cli_pkg/cli_pkg.dart' as pkg;
+import 'package:cli_util/cli_util.dart';
+import 'package:grinder/grinder.dart';
+import 'package:http/http.dart' as http;
+import 'package:path/path.dart' as p;
+import 'package:pubspec_parse/pubspec_parse.dart';
+import 'package:yaml/yaml.dart';
+
+import 'utils.dart';
+
+/// The path in which pub expects to find its credentials file.
+final String _pubCredentialsPath =
+ p.join(applicationConfigHome('dart'), 'pub-credentials.json');
+
+@Task('Deploy pkg/sass_api to pub.')
+Future deploySassApi() async {
+ // Write pub credentials
+ Directory(p.dirname(_pubCredentialsPath)).createSync(recursive: true);
+ File(_pubCredentialsPath).openSync(mode: FileMode.writeOnlyAppend)
+ ..writeStringSync(pkg.pubCredentials.value)
+ ..closeSync();
+
+ var client = http.Client();
+ var pubspecPath = "pkg/sass_api/pubspec.yaml";
+ var pubspec = Pubspec.parse(File(pubspecPath).readAsStringSync(),
+ sourceUrl: p.toUri(pubspecPath));
+
+ // Remove the dependency override on `sass`, because otherwise it will block
+ // publishing.
+ var pubspecYaml = Map.of(
+ loadYaml(File(pubspecPath).readAsStringSync()) as YamlMap);
+ pubspecYaml.remove("dependency_overrides");
+ File(pubspecPath).writeAsStringSync(json.encode(pubspecYaml));
+
+ // We use symlinks to avoid duplicating files between the main repo and
+ // child repos, but `pub lish` doesn't resolve these before publishing so we
+ // have to do so manually.
+ for (var entry in Directory("pkg/sass_api")
+ .listSync(recursive: true, followLinks: false)) {
+ if (entry is! Link) continue;
+ var target = p.join(p.dirname(entry.path), entry.targetSync());
+ entry.deleteSync();
+ File(entry.path).writeAsStringSync(File(target).readAsStringSync());
+ }
+
+ log("dart pub publish ${pubspec.name}");
+ var process = await Process.start(
+ p.join(sdkDir.path, "bin/dart"), ["pub", "publish", "--force"],
+ workingDirectory: "pkg/sass_api");
+ LineSplitter().bind(utf8.decoder.bind(process.stdout)).listen(log);
+ LineSplitter().bind(utf8.decoder.bind(process.stderr)).listen(log);
+ if (await process.exitCode != 0) {
+ fail("dart pub publish ${pubspec.name} failed");
+ }
+
+ var response = await client.post(
+ Uri.parse("https://api.github.com/repos/sass/dart-sass/git/refs"),
+ headers: {
+ "accept": "application/vnd.github.v3+json",
+ "content-type": "application/json",
+ "authorization": githubAuthorization
+ },
+ body: jsonEncode({
+ "ref": "refs/tags/${pubspec.name}/${pubspec.version}",
+ "sha": Platform.environment["GITHUB_SHA"]!
+ }));
+
+ if (response.statusCode != 201) {
+ fail("${response.statusCode} error creating tag:\n${response.body}");
+ } else {
+ log("Tagged ${pubspec.name} ${pubspec.version}.");
+ }
+}
diff --git a/tool/grind/subpackages.dart b/tool/grind/subpackages.dart
deleted file mode 100644
index c58719aeb..000000000
--- a/tool/grind/subpackages.dart
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright 2021 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 'dart:io';
-import 'dart:convert';
-
-import 'package:cli_pkg/cli_pkg.dart' as pkg;
-import 'package:cli_util/cli_util.dart';
-import 'package:grinder/grinder.dart';
-import 'package:http/http.dart' as http;
-import 'package:path/path.dart' as p;
-import 'package:pubspec_parse/pubspec_parse.dart';
-import 'package:yaml/yaml.dart';
-
-import 'utils.dart';
-
-/// The path in which pub expects to find its credentials file.
-final String _pubCredentialsPath =
- p.join(applicationConfigHome('dart'), 'pub-credentials.json');
-
-@Task('Deploy sub-packages to pub.')
-Future deploySubPackages() async {
- // Write pub credentials
- Directory(p.dirname(_pubCredentialsPath)).createSync(recursive: true);
- File(_pubCredentialsPath).openSync(mode: FileMode.writeOnlyAppend)
- ..writeStringSync(pkg.pubCredentials.value)
- ..closeSync();
-
- var client = http.Client();
- for (var package in Directory("pkg").listSync().map((dir) => dir.path)) {
- var pubspecPath = "$package/pubspec.yaml";
- var pubspec = Pubspec.parse(File(pubspecPath).readAsStringSync(),
- sourceUrl: p.toUri(pubspecPath));
-
- // Remove the dependency override on `sass`, because otherwise it will block
- // publishing.
- var pubspecYaml = Map.of(
- loadYaml(File(pubspecPath).readAsStringSync()) as YamlMap);
- pubspecYaml.remove("dependency_overrides");
- File(pubspecPath).writeAsStringSync(json.encode(pubspecYaml));
-
- // We use symlinks to avoid duplicating files between the main repo and
- // child repos, but `pub lish` doesn't resolve these before publishing so we
- // have to do so manually.
- for (var entry
- in Directory(package).listSync(recursive: true, followLinks: false)) {
- if (entry is! Link) continue;
- var target = p.join(p.dirname(entry.path), entry.targetSync());
- entry.deleteSync();
- File(entry.path).writeAsStringSync(File(target).readAsStringSync());
- }
-
- log("dart pub publish ${pubspec.name}");
- var process = await Process.start(
- p.join(sdkDir.path, "bin/dart"), ["pub", "publish", "--force"],
- workingDirectory: package);
- LineSplitter().bind(utf8.decoder.bind(process.stdout)).listen(log);
- LineSplitter().bind(utf8.decoder.bind(process.stderr)).listen(log);
- if (await process.exitCode != 0) {
- fail("dart pub publish ${pubspec.name} failed");
- }
-
- var response = await client.post(
- Uri.parse("https://api.github.com/repos/sass/dart-sass/git/refs"),
- headers: {
- "accept": "application/vnd.github.v3+json",
- "content-type": "application/json",
- "authorization": githubAuthorization
- },
- body: jsonEncode({
- "ref": "refs/tags/${pubspec.name}/${pubspec.version}",
- "sha": Platform.environment["GITHUB_SHA"]!
- }));
-
- if (response.statusCode != 201) {
- fail("${response.statusCode} error creating tag:\n${response.body}");
- } else {
- log("Tagged ${pubspec.name} ${pubspec.version}.");
- }
- }
-}