-
+
@@ -14,6 +14,8 @@ A [Dart][dart] implementation of [Sass][sass]. **Sass makes CSS fun**.
+
+
@@ -201,7 +203,7 @@ files, you'll need to pass a [custom importer] to [`compileString()`] or
[`compile()`]: https://sass-lang.com/documentation/js-api/functions/compile
[`compileAsync()`]: https://sass-lang.com/documentation/js-api/functions/compileAsync
-[custom importer]: https://sass-lang.com/documentation/js-api/interfaces/StringOptionsWithImporter#importer
+[custom importer]: https://sass-lang.com/documentation/js-api/interfaces/stringoptions/#importer
[`compileString()`]: https://sass-lang.com/documentation/js-api/functions/compileString
[`compileStringAsync()`]: https://sass-lang.com/documentation/js-api/functions/compileStringAsync
[legacy API]: #legacy-javascript-api
diff --git a/bin/sass.dart b/bin/sass.dart
index cc912d041..bd5684d25 100644
--- a/bin/sass.dart
+++ b/bin/sass.dart
@@ -15,7 +15,6 @@ import 'package:sass/src/executable/watch.dart';
import 'package:sass/src/import_cache.dart';
import 'package:sass/src/importer/filesystem.dart';
import 'package:sass/src/io.dart';
-import 'package:sass/src/logger/deprecation_processing.dart';
import 'package:sass/src/stylesheet_graph.dart';
import 'package:sass/src/utils.dart';
import 'package:sass/src/embedded/executable.dart'
@@ -48,16 +47,11 @@ Future main(List args) async {
var graph = StylesheetGraph(ImportCache(
importers: [...options.pkgImporters, FilesystemImporter.noLoadPath],
loadPaths: options.loadPaths,
- // This logger is only used for handling fatal/future deprecations
- // during parsing, and is re-used across parses, so we don't want to
- // limit repetition. A separate DeprecationHandlingLogger is created for
- // each compilation, which will limit repetition if verbose is not
- // passed in addition to handling fatal/future deprecations.
- logger: DeprecationProcessingLogger(options.logger,
- silenceDeprecations: options.silenceDeprecations,
- fatalDeprecations: options.fatalDeprecations,
- futureDeprecations: options.futureDeprecations,
- limitRepetition: false)));
+ logger: ImportCache.wrapLogger(
+ options.logger,
+ options.silenceDeprecations,
+ options.fatalDeprecations,
+ options.futureDeprecations)));
if (options.watch) {
await watch(options, graph);
return;
diff --git a/lib/sass.dart b/lib/sass.dart
index 82ebf72c6..39f14f7b1 100644
--- a/lib/sass.dart
+++ b/lib/sass.dart
@@ -120,7 +120,9 @@ CompileResult compileToResult(String path,
logger: logger,
importCache: ImportCache(
importers: importers,
- logger: logger ?? Logger.stderr(color: color),
+ logger: ImportCache.wrapLogger(logger, silenceDeprecations,
+ fatalDeprecations, futureDeprecations,
+ color: color),
loadPaths: loadPaths,
packageConfig: packageConfig),
functions: functions,
@@ -222,7 +224,9 @@ CompileResult compileStringToResult(String source,
logger: logger,
importCache: ImportCache(
importers: importers,
- logger: logger ?? Logger.stderr(color: color),
+ logger: ImportCache.wrapLogger(logger, silenceDeprecations,
+ fatalDeprecations, futureDeprecations,
+ color: color),
packageConfig: packageConfig,
loadPaths: loadPaths),
functions: functions,
@@ -261,7 +265,9 @@ Future compileToResultAsync(String path,
logger: logger,
importCache: AsyncImportCache(
importers: importers,
- logger: logger ?? Logger.stderr(color: color),
+ logger: AsyncImportCache.wrapLogger(logger, silenceDeprecations,
+ fatalDeprecations, futureDeprecations,
+ color: color),
loadPaths: loadPaths,
packageConfig: packageConfig),
functions: functions,
@@ -304,7 +310,9 @@ Future compileStringToResultAsync(String source,
logger: logger,
importCache: AsyncImportCache(
importers: importers,
- logger: logger ?? Logger.stderr(color: color),
+ logger: AsyncImportCache.wrapLogger(logger, silenceDeprecations,
+ fatalDeprecations, futureDeprecations,
+ color: color),
packageConfig: packageConfig,
loadPaths: loadPaths),
functions: functions,
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/stylesheet.dart b/lib/src/ast/sass/statement/stylesheet.dart
index b90cddc52..1ae060854 100644
--- a/lib/src/ast/sass/statement/stylesheet.dart
+++ b/lib/src/ast/sass/statement/stylesheet.dart
@@ -91,8 +91,6 @@ final class Stylesheet extends ParentStatement> {
return Stylesheet.parseScss(contents, url: url, logger: logger);
case Syntax.css:
return Stylesheet.parseCss(contents, url: url, logger: logger);
- default:
- throw ArgumentError("Unknown syntax $syntax.");
}
} on SassException catch (error, stackTrace) {
var url = error.span.sourceUrl;
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/async_compile.dart b/lib/src/async_compile.dart
index 063f3d2dc..ea701f598 100644
--- a/lib/src/async_compile.dart
+++ b/lib/src/async_compile.dart
@@ -45,12 +45,13 @@ Future compileAsync(String path,
Iterable? silenceDeprecations,
Iterable? fatalDeprecations,
Iterable? futureDeprecations}) async {
- DeprecationProcessingLogger deprecationLogger = logger =
- DeprecationProcessingLogger(logger ?? Logger.stderr(),
+ DeprecationProcessingLogger deprecationLogger =
+ logger = DeprecationProcessingLogger(logger ?? Logger.stderr(),
silenceDeprecations: {...?silenceDeprecations},
fatalDeprecations: {...?fatalDeprecations},
futureDeprecations: {...?futureDeprecations},
- limitRepetition: !verbose);
+ limitRepetition: !verbose)
+ ..validate();
// If the syntax is different than the importer would default to, we have to
// parse the file manually and we can't store it in the cache.
@@ -111,12 +112,13 @@ Future compileStringAsync(String source,
Iterable? silenceDeprecations,
Iterable? fatalDeprecations,
Iterable? futureDeprecations}) async {
- DeprecationProcessingLogger deprecationLogger = logger =
- DeprecationProcessingLogger(logger ?? Logger.stderr(),
+ DeprecationProcessingLogger deprecationLogger =
+ logger = DeprecationProcessingLogger(logger ?? Logger.stderr(),
silenceDeprecations: {...?silenceDeprecations},
fatalDeprecations: {...?fatalDeprecations},
futureDeprecations: {...?futureDeprecations},
- limitRepetition: !verbose);
+ limitRepetition: !verbose)
+ ..validate();
var stylesheet =
Stylesheet.parse(source, syntax ?? Syntax.scss, url: url, logger: logger);
diff --git a/lib/src/async_import_cache.dart b/lib/src/async_import_cache.dart
index 6704249d4..91713ca7b 100644
--- a/lib/src/async_import_cache.dart
+++ b/lib/src/async_import_cache.dart
@@ -16,6 +16,7 @@ import 'importer/no_op.dart';
import 'importer/utils.dart';
import 'io.dart';
import 'logger.dart';
+import 'logger/deprecation_processing.dart';
import 'util/map.dart';
import 'util/nullable.dart';
import 'utils.dart';
@@ -61,7 +62,7 @@ final class AsyncImportCache {
<(AsyncImporter, Uri, {bool forImport}), AsyncCanonicalizeResult?>{};
/// A map from the keys in [_perImporterCanonicalizeCache] that are generated
- /// for relative URL loads agains the base importer to the original relative
+ /// for relative URL loads against the base importer to the original relative
/// URLs what were loaded.
///
/// This is used to invalidate the cache when files are changed.
@@ -97,18 +98,18 @@ final class AsyncImportCache {
PackageConfig? packageConfig,
Logger? logger})
: _importers = _toImporters(importers, loadPaths, packageConfig),
- _logger = logger ?? const Logger.stderr();
+ _logger = logger ?? Logger.stderr();
/// Creates an import cache without any globally-available importers.
AsyncImportCache.none({Logger? logger})
: _importers = const [],
- _logger = logger ?? const Logger.stderr();
+ _logger = logger ?? Logger.stderr();
/// Creates an import cache without any globally-available importers, and only
/// the passed in importers.
AsyncImportCache.only(Iterable importers, {Logger? logger})
: _importers = List.unmodifiable(importers),
- _logger = logger ?? const Logger.stderr();
+ _logger = logger ?? Logger.stderr();
/// Converts the user's [importers], [loadPaths], and [packageConfig]
/// options into a single list of importers.
@@ -173,11 +174,11 @@ final class AsyncImportCache {
var key = (url, forImport: forImport);
if (_canonicalizeCache.containsKey(key)) return _canonicalizeCache[key];
- // Each indivudal call to a `canonicalize()` override may not be cacheable
+ // Each individual call to a `canonicalize()` override may not be cacheable
// (specifically, if it has access to `containingUrl` it's too
// context-sensitive to usefully cache). We want to cache a given URL across
// the _entire_ importer chain, so we use [cacheable] to track whether _all_
- // `canonicalize()` calls we've attempted are cacheable. Only if they are do
+ // `canonicalize()` calls we've attempted are cacheable. Only if they are, do
// we store the result in the cache.
var cacheable = true;
for (var i = 0; i < _importers.length; i++) {
@@ -360,4 +361,25 @@ final class AsyncImportCache {
_resultsCache.remove(canonicalUrl);
_importCache.remove(canonicalUrl);
}
+
+ /// Wraps [logger] to process deprecations within an ImportCache.
+ ///
+ /// This wrapped logger will handle the deprecation options, but will not
+ /// limit repetition, as it can be re-used across parses. A logger passed to
+ /// an ImportCache or AsyncImportCache should generally be wrapped here first,
+ /// unless it's already been wrapped to process deprecations, in which case
+ /// this method has no effect.
+ static DeprecationProcessingLogger wrapLogger(
+ Logger? logger,
+ Iterable? silenceDeprecations,
+ Iterable? fatalDeprecations,
+ Iterable? futureDeprecations,
+ {bool color = false}) {
+ if (logger is DeprecationProcessingLogger) return logger;
+ return DeprecationProcessingLogger(logger ?? Logger.stderr(color: color),
+ silenceDeprecations: {...?silenceDeprecations},
+ fatalDeprecations: {...?fatalDeprecations},
+ futureDeprecations: {...?futureDeprecations},
+ limitRepetition: false);
+ }
}
diff --git a/lib/src/callable.dart b/lib/src/callable.dart
index 2d2ed1e26..1fda63247 100644
--- a/lib/src/callable.dart
+++ b/lib/src/callable.dart
@@ -11,7 +11,8 @@ import 'utils.dart';
import 'value.dart';
export 'callable/async.dart';
-export 'callable/async_built_in.dart' show AsyncBuiltInCallable;
+export 'callable/async_built_in.dart'
+ show AsyncBuiltInCallable, warnForGlobalBuiltIn;
export 'callable/built_in.dart' show BuiltInCallable;
export 'callable/plain_css.dart';
export 'callable/user_defined.dart';
diff --git a/lib/src/callable/async_built_in.dart b/lib/src/callable/async_built_in.dart
index 7dba7e1cd..2c764ee0c 100644
--- a/lib/src/callable/async_built_in.dart
+++ b/lib/src/callable/async_built_in.dart
@@ -5,6 +5,8 @@
import 'dart:async';
import '../ast/sass.dart';
+import '../deprecation.dart';
+import '../evaluation_context.dart';
import '../value.dart';
import 'async.dart';
@@ -83,4 +85,22 @@ class AsyncBuiltInCallable implements AsyncCallable {
(ArgumentDeclaration, Callback) callbackFor(
int positional, Set names) =>
(_arguments, _callback);
+
+ /// Returns a copy of this callable that emits a deprecation warning.
+ AsyncBuiltInCallable withDeprecationWarning(String module,
+ [String? newName]) =>
+ AsyncBuiltInCallable.parsed(name, _arguments, (args) {
+ warnForGlobalBuiltIn(module, newName ?? name);
+ return _callback(args);
+ }, acceptsContent: acceptsContent);
+}
+
+/// Emits a deprecation warning for a global built-in function that is now
+/// available as function [name] in built-in module [module].
+void warnForGlobalBuiltIn(String module, String name) {
+ warnForDeprecation(
+ 'Global built-in functions will be deprecated in the future.\n'
+ 'Remove the --future-deprecation=global-builtin flag to silence this '
+ 'warning for now.',
+ Deprecation.globalBuiltin);
}
diff --git a/lib/src/callable/built_in.dart b/lib/src/callable/built_in.dart
index a52662fa0..1d58df9fe 100644
--- a/lib/src/callable/built_in.dart
+++ b/lib/src/callable/built_in.dart
@@ -123,4 +123,20 @@ final class BuiltInCallable implements Callable, AsyncBuiltInCallable {
/// Returns a copy of this callable with the given [name].
BuiltInCallable withName(String name) =>
BuiltInCallable._(name, _overloads, acceptsContent);
+
+ /// Returns a copy of this callable that emits a deprecation warning.
+ BuiltInCallable withDeprecationWarning(String module, [String? newName]) =>
+ BuiltInCallable._(
+ name,
+ [
+ for (var (declaration, function) in _overloads)
+ (
+ declaration,
+ (args) {
+ warnForGlobalBuiltIn(module, newName ?? name);
+ return function(args);
+ }
+ )
+ ],
+ acceptsContent);
}
diff --git a/lib/src/compile.dart b/lib/src/compile.dart
index 5a7fe54f2..6854dd68e 100644
--- a/lib/src/compile.dart
+++ b/lib/src/compile.dart
@@ -5,7 +5,7 @@
// DO NOT EDIT. This file was generated from async_compile.dart.
// See tool/grind/synchronize.dart for details.
//
-// Checksum: ab2c6fa2588988a86abdbe87512134098e01b39e
+// Checksum: 69b31749dc94c7f717e9d395327e4209c4d3feb0
//
// ignore_for_file: unused_import
@@ -54,12 +54,13 @@ CompileResult compile(String path,
Iterable? silenceDeprecations,
Iterable? fatalDeprecations,
Iterable? futureDeprecations}) {
- DeprecationProcessingLogger deprecationLogger = logger =
- DeprecationProcessingLogger(logger ?? Logger.stderr(),
+ DeprecationProcessingLogger deprecationLogger =
+ logger = DeprecationProcessingLogger(logger ?? Logger.stderr(),
silenceDeprecations: {...?silenceDeprecations},
fatalDeprecations: {...?fatalDeprecations},
futureDeprecations: {...?futureDeprecations},
- limitRepetition: !verbose);
+ limitRepetition: !verbose)
+ ..validate();
// If the syntax is different than the importer would default to, we have to
// parse the file manually and we can't store it in the cache.
@@ -120,12 +121,13 @@ CompileResult compileString(String source,
Iterable? silenceDeprecations,
Iterable? fatalDeprecations,
Iterable? futureDeprecations}) {
- DeprecationProcessingLogger deprecationLogger = logger =
- DeprecationProcessingLogger(logger ?? Logger.stderr(),
+ DeprecationProcessingLogger deprecationLogger =
+ logger = DeprecationProcessingLogger(logger ?? Logger.stderr(),
silenceDeprecations: {...?silenceDeprecations},
fatalDeprecations: {...?fatalDeprecations},
futureDeprecations: {...?futureDeprecations},
- limitRepetition: !verbose);
+ limitRepetition: !verbose)
+ ..validate();
var stylesheet =
Stylesheet.parse(source, syntax ?? Syntax.scss, url: url, logger: logger);
diff --git a/lib/src/deprecation.dart b/lib/src/deprecation.dart
index fc5d678b0..097ece323 100644
--- a/lib/src/deprecation.dart
+++ b/lib/src/deprecation.dart
@@ -15,7 +15,7 @@ enum Deprecation {
// DO NOT EDIT. This section was generated from the language repo.
// See tool/grind/generate_deprecations.dart for details.
//
- // Checksum: b87f31f543f52c97431cce85ef11e92b6a71cfde
+ // Checksum: 5470e7252641d3eaa7093b072b52e423c3b77375
/// Deprecation for passing a string directly to meta.call().
callString('call-string',
@@ -95,6 +95,10 @@ enum Deprecation {
deprecatedIn: '1.77.7',
description: 'Declarations after or between nested rules.'),
+ /// Deprecation for meta.feature-exists
+ featureExists('feature-exists',
+ deprecatedIn: '1.78.0', description: 'meta.feature-exists'),
+
/// Deprecation for certain uses of built-in sass:color functions.
color4Api('color-4-api',
deprecatedIn: '1.79.0',
@@ -108,6 +112,11 @@ enum Deprecation {
/// Deprecation for @import rules.
import.future('import', description: '@import rules.'),
+ /// Deprecation for global built-in functions that are available in sass: modules.
+ globalBuiltin.future('global-builtin',
+ description:
+ 'Global built-in functions that are available in sass: modules.'),
+
// END AUTOGENERATED CODE
/// Used for deprecations coming from user-authored code.
diff --git a/lib/src/embedded/compilation_dispatcher.dart b/lib/src/embedded/compilation_dispatcher.dart
index 8f69b2553..a8c84c8eb 100644
--- a/lib/src/embedded/compilation_dispatcher.dart
+++ b/lib/src/embedded/compilation_dispatcher.dart
@@ -51,28 +51,15 @@ final class CompilationDispatcher {
/// This is used in outgoing messages.
late Uint8List _compilationIdVarint;
- /// Whether we detected a [ProtocolError] while parsing an incoming response.
- ///
- /// If we have, we don't want to send the final compilation result because
- /// it'll just be a wrapper around the error.
- var _requestError = false;
-
/// Creates a [CompilationDispatcher] that receives encoded protocol buffers
/// through [_mailbox] and sends them through [_sendPort].
CompilationDispatcher(this._mailbox, this._sendPort);
/// Listens for incoming `CompileRequests` and runs their compilations.
void listen() {
- do {
- Uint8List packet;
- try {
- packet = _mailbox.take();
- } on StateError catch (_) {
- break;
- }
-
+ while (true) {
try {
- var (compilationId, messageBuffer) = parsePacket(packet);
+ var (compilationId, messageBuffer) = parsePacket(_receive());
_compilationId = compilationId;
_compilationIdVarint = serializeVarint(compilationId);
@@ -88,9 +75,7 @@ final class CompilationDispatcher {
case InboundMessage_Message.compileRequest:
var request = message.compileRequest;
var response = _compile(request);
- if (!_requestError) {
- _send(OutboundMessage()..compileResponse = response);
- }
+ _send(OutboundMessage()..compileResponse = response);
case InboundMessage_Message.versionRequest:
throw paramsError("VersionRequest must have compilation ID 0.");
@@ -113,7 +98,7 @@ final class CompilationDispatcher {
} catch (error, stackTrace) {
_handleError(error, stackTrace);
}
- } while (!_requestError);
+ }
}
OutboundMessage_CompileResponse _compile(
@@ -287,20 +272,13 @@ final class CompilationDispatcher {
void sendLog(OutboundMessage_LogEvent event) =>
_send(OutboundMessage()..logEvent = event);
- /// Sends [error] to the host.
+ /// Sends [error] to the host and exit.
///
/// This is used during compilation by other classes like host callable.
- /// Therefore it must set _requestError = true to prevent sending a CompileFailure after
- /// sending a ProtocolError.
- void sendError(ProtocolError error) {
- _sendError(error);
- _requestError = true;
+ Never sendError(ProtocolError error) {
+ Isolate.exit(_sendPort, _serializePacket(OutboundMessage()..error = error));
}
- /// Sends [error] to the host.
- void _sendError(ProtocolError error) =>
- _send(OutboundMessage()..error = error);
-
InboundMessage_CanonicalizeResponse sendCanonicalizeRequest(
OutboundMessage_CanonicalizeRequest request) =>
_sendRequest(
@@ -326,19 +304,9 @@ final class CompilationDispatcher {
message.id = _outboundRequestId;
_send(message);
- Uint8List packet;
- try {
- packet = _mailbox.take();
- } on StateError catch (_) {
- // Compiler is shutting down, throw without calling `_handleError` as we
- // don't want to report this as an actual error.
- _requestError = true;
- rethrow;
- }
-
try {
var messageBuffer =
- Uint8List.sublistView(packet, _compilationIdVarint.length);
+ Uint8List.sublistView(_receive(), _compilationIdVarint.length);
InboundMessage message;
try {
@@ -376,8 +344,6 @@ final class CompilationDispatcher {
return response;
} catch (error, stackTrace) {
_handleError(error, stackTrace);
- _requestError = true;
- rethrow;
}
}
@@ -385,12 +351,17 @@ final class CompilationDispatcher {
///
/// The [messageId] indicate the IDs of the message being responded to, if
/// available.
- void _handleError(Object error, StackTrace stackTrace, {int? messageId}) {
- _sendError(handleError(error, stackTrace, messageId: messageId));
+ Never _handleError(Object error, StackTrace stackTrace, {int? messageId}) {
+ sendError(handleError(error, stackTrace, messageId: messageId));
}
/// Sends [message] to the host with the given [wireId].
void _send(OutboundMessage message) {
+ _sendPort.send(_serializePacket(message));
+ }
+
+ /// Serialize [message] to [Uint8List].
+ Uint8List _serializePacket(OutboundMessage message) {
var protobufWriter = CodedBufferWriter();
message.writeToCodedBufferWriter(protobufWriter);
@@ -407,6 +378,17 @@ final class CompilationDispatcher {
};
packet.setAll(1, _compilationIdVarint);
protobufWriter.writeTo(packet, 1 + _compilationIdVarint.length);
- _sendPort.send(packet);
+ return packet;
+ }
+
+ /// Receive a packet from the host.
+ Uint8List _receive() {
+ try {
+ return _mailbox.take();
+ } on StateError catch (_) {
+ // The [_mailbox] has been closed, exit the current isolate immediately
+ // to avoid bubble the error up as [SassException] during [_sendRequest].
+ Isolate.exit();
+ }
}
}
diff --git a/lib/src/embedded/host_callable.dart b/lib/src/embedded/host_callable.dart
index 06c3acb66..95e15221a 100644
--- a/lib/src/embedded/host_callable.dart
+++ b/lib/src/embedded/host_callable.dart
@@ -53,7 +53,6 @@ Callable hostCallable(
}
} on ProtocolError catch (error, stackTrace) {
dispatcher.sendError(handleError(error, stackTrace));
- throw error.message;
}
});
return callable;
diff --git a/lib/src/embedded/isolate_dispatcher.dart b/lib/src/embedded/isolate_dispatcher.dart
index b208cc6b3..fe79f034c 100644
--- a/lib/src/embedded/isolate_dispatcher.dart
+++ b/lib/src/embedded/isolate_dispatcher.dart
@@ -4,6 +4,7 @@
import 'dart:async';
import 'dart:ffi';
+import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
@@ -27,13 +28,13 @@ class IsolateDispatcher {
/// All isolates that have been spawned to dispatch to.
///
/// Only used for cleaning up the process when the underlying channel closes.
- final _allIsolates = >[];
+ final _allIsolates = StreamController(sync: true);
/// The isolates that aren't currently running compilations
final _inactiveIsolates = {};
/// A map from active compilationIds to isolates running those compilations.
- final _activeIsolates = {};
+ final _activeIsolates = >{};
/// A pool controlling how many isolates (and thus concurrent compilations)
/// may be live at once.
@@ -43,6 +44,9 @@ class IsolateDispatcher {
/// See https://github.com/sass/dart-sass/pull/2019
final _isolatePool = Pool(sizeOf() <= 4 ? 7 : 15);
+ /// Whether [_channel] has been closed or not.
+ var _closed = false;
+
IsolateDispatcher(this._channel);
void listen() {
@@ -54,8 +58,12 @@ class IsolateDispatcher {
(compilationId, messageBuffer) = parsePacket(packet);
if (compilationId != 0) {
- var isolate = _activeIsolates[compilationId] ??
- await _getIsolate(compilationId);
+ var isolate = await _activeIsolates.putIfAbsent(
+ compilationId, () => _getIsolate(compilationId!));
+
+ // The shutdown may have started by the time the isolate is spawned
+ if (_closed) return;
+
try {
isolate.send(packet);
return;
@@ -87,10 +95,9 @@ class IsolateDispatcher {
}
}, onError: (Object error, StackTrace stackTrace) {
_handleError(error, stackTrace);
- }, onDone: () async {
- for (var isolate in _allIsolates) {
- (await isolate).kill();
- }
+ }, onDone: () {
+ _closed = true;
+ _allIsolates.stream.listen((isolate) => isolate.kill());
});
}
@@ -105,27 +112,40 @@ class IsolateDispatcher {
isolate = _inactiveIsolates.first;
_inactiveIsolates.remove(isolate);
} else {
- var future = ReusableIsolate.spawn(_isolateMain);
- _allIsolates.add(future);
+ var future = ReusableIsolate.spawn(_isolateMain,
+ onError: (Object error, StackTrace stackTrace) {
+ _handleError(error, stackTrace);
+ });
isolate = await future;
+ _allIsolates.add(isolate);
}
- _activeIsolates[compilationId] = isolate;
- isolate.checkOut().listen(_channel.sink.add,
- onError: (Object error, StackTrace stackTrace) {
- if (error is ProtocolError) {
- // Protocol errors have already been through [_handleError] in the child
- // isolate, so we just send them as-is and close out the underlying
- // channel.
- sendError(compilationId, error);
- _channel.sink.close();
- } else {
- _handleError(error, stackTrace);
+ isolate.borrow((message) {
+ var fullBuffer = message as Uint8List;
+
+ // The first byte of messages from isolates indicates whether the entire
+ // compilation is finished (1) or if it encountered an error (2). Sending
+ // this as part of the message buffer rather than a separate message
+ // avoids a race condition where the host might send a new compilation
+ // request with the same ID as one that just finished before the
+ // [IsolateDispatcher] receives word that the isolate with that ID is
+ // done. See sass/dart-sass#2004.
+ var category = fullBuffer[0];
+ var packet = Uint8List.sublistView(fullBuffer, 1);
+
+ switch (category) {
+ case 0:
+ _channel.sink.add(packet);
+ case 1:
+ _activeIsolates.remove(compilationId);
+ isolate.release();
+ _inactiveIsolates.add(isolate);
+ resource.release();
+ _channel.sink.add(packet);
+ case 2:
+ _channel.sink.add(packet);
+ exit(exitCode);
}
- }, onDone: () {
- _activeIsolates.remove(compilationId);
- _inactiveIsolates.add(isolate);
- resource.release();
});
return isolate;
diff --git a/lib/src/embedded/reusable_isolate.dart b/lib/src/embedded/reusable_isolate.dart
index 5cdd6bbd8..4140f5a37 100644
--- a/lib/src/embedded/reusable_isolate.dart
+++ b/lib/src/embedded/reusable_isolate.dart
@@ -8,18 +8,16 @@ import 'dart:typed_data';
import 'package:native_synchronization/mailbox.dart';
import 'package:native_synchronization/sendable.dart';
-import 'embedded_sass.pb.dart';
-import 'utils.dart';
/// The entrypoint for a [ReusableIsolate].
///
/// This must be a static global function. It's run when the isolate is spawned,
/// and is passed a [Mailbox] that receives messages from [ReusableIsolate.send]
-/// and a [SendPort] that sends messages to the stream returned by
-/// [ReusableIsolate.checkOut].
+/// and a [SendPort] that sends messages to the [ReceivePort] listened by
+/// [ReusableIsolate.borrow].
///
-/// If the [sendPort] sends a message before [ReusableIsolate.checkOut] is
-/// called, this will throw an unhandled [StateError].
+/// If the [sendPort] sends a message before [ReusableIsolate.borrow] is called,
+/// this will throw an unhandled [StateError].
typedef ReusableIsolateEntryPoint = FutureOr Function(
Mailbox mailbox, SendPort sink);
@@ -33,104 +31,70 @@ class ReusableIsolate {
/// The [ReceivePort] that receives messages from the wrapped isolate.
final ReceivePort _receivePort;
- /// The subscription to [_port].
- final StreamSubscription _subscription;
+ /// The subscription to [_receivePort].
+ final StreamSubscription _subscription;
- /// Whether [checkOut] has been called and the returned stream has not yet
- /// closed.
- bool _checkedOut = false;
+ /// Whether the current isolate has been borrowed.
+ bool _borrowed = false;
- ReusableIsolate._(this._isolate, this._mailbox, this._receivePort)
- : _subscription = _receivePort.listen(_defaultOnData);
+ ReusableIsolate._(this._isolate, this._mailbox, this._receivePort,
+ {Function? onError})
+ : _subscription = _receivePort.listen(_defaultOnData, onError: onError);
/// Spawns a [ReusableIsolate] that runs the given [entryPoint].
- static Future spawn(
- ReusableIsolateEntryPoint entryPoint) async {
+ static Future spawn(ReusableIsolateEntryPoint entryPoint,
+ {Function? onError}) async {
var mailbox = Mailbox();
var receivePort = ReceivePort();
var isolate = await Isolate.spawn(
_isolateMain, (entryPoint, mailbox.asSendable, receivePort.sendPort));
- return ReusableIsolate._(isolate, mailbox, receivePort);
+ return ReusableIsolate._(isolate, mailbox, receivePort, onError: onError);
}
- /// Checks out this isolate and returns a stream of messages from it.
- ///
- /// This isolate is considered "checked out" until the returned stream
- /// completes. While checked out, messages may be sent to the isolate using
- /// [send].
- ///
- /// Throws a [StateError] if this is called while the isolate is already
- /// checked out.
- Stream checkOut() {
- if (_checkedOut) {
- throw StateError(
- "Can't call ResuableIsolate.checkOut until the previous stream has "
- "completed.");
+ /// Subscribe to messages from [_receivePort].
+ void borrow(void onData(dynamic event)?) {
+ if (_borrowed) {
+ throw StateError('ReusableIsolate has already been borrowed.');
+ }
+ _borrowed = true;
+ _subscription.onData(onData);
+ }
+
+ /// Unsubscribe to messages from [_receivePort].
+ void release() {
+ if (!_borrowed) {
+ throw StateError('ReusableIsolate has not been borrowed.');
}
- _checkedOut = true;
-
- var controller = StreamController(sync: true);
-
- _subscription.onData((message) {
- var fullBuffer = message as Uint8List;
-
- // The first byte of messages from isolates indicates whether the entire
- // compilation is finished (1) or if it encountered an error (2). Sending
- // this as part of the message buffer rather than a separate message
- // avoids a race condition where the host might send a new compilation
- // request with the same ID as one that just finished before the
- // [IsolateDispatcher] receives word that the isolate with that ID is
- // done. See sass/dart-sass#2004.
- var category = fullBuffer[0];
- var packet = Uint8List.sublistView(fullBuffer, 1);
-
- if (category == 2) {
- // Parse out the compilation ID and surface the [ProtocolError] as an
- // error. This allows the [IsolateDispatcher] to notice that an error
- // has occurred and close out the underlying channel.
- var (_, buffer) = parsePacket(packet);
- controller.addError(OutboundMessage.fromBuffer(buffer).error);
- return;
- }
-
- controller.sink.add(packet);
- if (category == 1) {
- _checkedOut = false;
- _subscription.onData(_defaultOnData);
- _subscription.onError(null);
- controller.close();
- }
- });
-
- _subscription.onError(controller.addError);
-
- return controller.stream;
+ _borrowed = false;
+ _subscription.onData(_defaultOnData);
}
/// Sends [message] to the isolate.
///
- /// Throws a [StateError] if this is called while the isolate isn't checked
- /// out, or if a second message is sent before the isolate has processed the
- /// first one.
+ /// Throws a [StateError] if this is called while the isolate isn't borrowed,
+ /// or if a second message is sent before the isolate has processed the first
+ /// one.
void send(Uint8List message) {
+ if (!_borrowed) {
+ throw StateError('Cannot send a message before being borrowed.');
+ }
_mailbox.put(message);
}
/// Shuts down the isolate.
void kill() {
- _isolate.kill();
- _receivePort.close();
-
// If the isolate is blocking on [Mailbox.take], it won't even process a
// kill event, so we closed the mailbox to nofity and wake it up.
_mailbox.close();
+ _isolate.kill(priority: Isolate.immediate);
+ _receivePort.close();
}
}
/// The default handler for data events from the wrapped isolate when it's not
-/// checked out.
-void _defaultOnData(Object? _) {
- throw StateError("Shouldn't receive a message before being checked out.");
+/// borrowed.
+void _defaultOnData(dynamic _) {
+ throw StateError("Shouldn't receive a message before being borrowed.");
}
void _isolateMain(
diff --git a/lib/src/executable/compile_stylesheet.dart b/lib/src/executable/compile_stylesheet.dart
index 3dbf3dbe0..0993e63e2 100644
--- a/lib/src/executable/compile_stylesheet.dart
+++ b/lib/src/executable/compile_stylesheet.dart
@@ -97,7 +97,11 @@ Future _compileStylesheetWithoutErrorHandling(ExecutableOptions options,
var importCache = AsyncImportCache(
importers: options.pkgImporters,
loadPaths: options.loadPaths,
- logger: options.logger);
+ logger: AsyncImportCache.wrapLogger(
+ options.logger,
+ options.silenceDeprecations,
+ options.fatalDeprecations,
+ options.futureDeprecations));
result = source == null
? await compileStringAsync(await readStdin(),
diff --git a/lib/src/executable/repl.dart b/lib/src/executable/repl.dart
index f79e2de33..ad9ad5ecd 100644
--- a/lib/src/executable/repl.dart
+++ b/lib/src/executable/repl.dart
@@ -26,7 +26,8 @@ Future repl(ExecutableOptions options) async {
silenceDeprecations: options.silenceDeprecations,
fatalDeprecations: options.fatalDeprecations,
futureDeprecations: options.futureDeprecations,
- limitRepetition: !options.verbose);
+ limitRepetition: !options.verbose)
+ ..validate();
var evaluator = Evaluator(
importer: FilesystemImporter.cwd,
importCache: ImportCache(
diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart
index 9c4467453..3745f3f72 100644
--- a/lib/src/functions/color.dart
+++ b/lib/src/functions/color.dart
@@ -2,7 +2,6 @@
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
-import 'dart:collection';
import 'dart:math' as math;
import 'package:collection/collection.dart';
@@ -31,10 +30,13 @@ const _specialCommaSpaces = {ColorSpace.rgb, ColorSpace.hsl};
/// The global definitions of Sass color functions.
final global = UnmodifiableListView([
// ### RGB
- _channelFunction("red", (color) => color.red, global: true),
- _channelFunction("green", (color) => color.green, global: true),
- _channelFunction("blue", (color) => color.blue, global: true),
- _mix,
+ _channelFunction("red", (color) => color.red, global: true)
+ .withDeprecationWarning("color"),
+ _channelFunction("green", (color) => color.green, global: true)
+ .withDeprecationWarning("color"),
+ _channelFunction("blue", (color) => color.blue, global: true)
+ .withDeprecationWarning("color"),
+ _mix.withDeprecationWarning("color"),
BuiltInCallable.overloadedFunction("rgb", {
r"$red, $green, $blue, $alpha": (arguments) => _rgb("rgb", arguments),
@@ -53,14 +55,18 @@ final global = UnmodifiableListView([
}),
_function("invert", r"$color, $weight: 100%, $space: null",
- (arguments) => _invert(arguments, global: true)),
+ (arguments) => _invert(arguments, global: true))
+ .withDeprecationWarning("color"),
// ### HSL
- _channelFunction("hue", (color) => color.hue, unit: 'deg', global: true),
+ _channelFunction("hue", (color) => color.hue, unit: 'deg', global: true)
+ .withDeprecationWarning("color"),
_channelFunction("saturation", (color) => color.saturation,
- unit: '%', global: true),
+ unit: '%', global: true)
+ .withDeprecationWarning("color"),
_channelFunction("lightness", (color) => color.lightness,
- unit: '%', global: true),
+ unit: '%', global: true)
+ .withDeprecationWarning("color"),
BuiltInCallable.overloadedFunction("hsl", {
r"$hue, $saturation, $lightness, $alpha": (arguments) =>
@@ -94,13 +100,16 @@ final global = UnmodifiableListView([
space: ColorSpace.hsl, name: 'channels')
}),
- _function(
- "grayscale",
- r"$color",
- (arguments) => arguments[0] is SassNumber || arguments[0].isSpecialNumber
- // Use the native CSS `grayscale` filter function.
- ? _functionString('grayscale', arguments)
- : _grayscale(arguments[0])),
+ _function("grayscale", r"$color", (arguments) {
+ if (arguments[0] is SassNumber || arguments[0].isSpecialNumber) {
+ // Use the native CSS `grayscale` filter function.
+ return _functionString('grayscale', arguments);
+ } else {
+ warnForGlobalBuiltIn('color', 'grayscale');
+
+ return _grayscale(arguments[0]);
+ }
+ }),
_function("adjust-hue", r"$color, $degrees", (arguments) {
var color = arguments[0].assertColor("color");
@@ -122,7 +131,7 @@ final global = UnmodifiableListView([
Deprecation.colorFunctions);
return color.changeHsl(hue: color.hue + degrees);
- }),
+ }).withDeprecationWarning('color', 'adjust'),
_function("lighten", r"$color, $amount", (arguments) {
var color = arguments[0].assertColor("color");
@@ -144,7 +153,7 @@ final global = UnmodifiableListView([
"More info: https://sass-lang.com/d/color-functions",
Deprecation.colorFunctions);
return result;
- }),
+ }).withDeprecationWarning('color', 'adjust'),
_function("darken", r"$color, $amount", (arguments) {
var color = arguments[0].assertColor("color");
@@ -166,7 +175,7 @@ final global = UnmodifiableListView([
"More info: https://sass-lang.com/d/color-functions",
Deprecation.colorFunctions);
return result;
- }),
+ }).withDeprecationWarning('color', 'adjust'),
BuiltInCallable.overloadedFunction("saturate", {
r"$amount": (arguments) {
@@ -178,6 +187,7 @@ final global = UnmodifiableListView([
return SassString("saturate(${number.toCssString()})", quotes: false);
},
r"$color, $amount": (arguments) {
+ warnForGlobalBuiltIn('color', 'adjust');
var color = arguments[0].assertColor("color");
var amount = arguments[1].assertNumber("amount");
if (!color.isLegacy) {
@@ -222,29 +232,38 @@ final global = UnmodifiableListView([
"More info: https://sass-lang.com/d/color-functions",
Deprecation.colorFunctions);
return result;
- }),
+ }).withDeprecationWarning('color', 'adjust'),
// ### Opacity
_function("opacify", r"$color, $amount",
- (arguments) => _opacify("opacify", arguments)),
+ (arguments) => _opacify("opacify", arguments))
+ .withDeprecationWarning('color', 'adjust'),
_function("fade-in", r"$color, $amount",
- (arguments) => _opacify("fade-in", arguments)),
+ (arguments) => _opacify("fade-in", arguments))
+ .withDeprecationWarning('color', 'adjust'),
_function("transparentize", r"$color, $amount",
- (arguments) => _transparentize("transparentize", arguments)),
+ (arguments) => _transparentize("transparentize", arguments))
+ .withDeprecationWarning('color', 'adjust'),
_function("fade-out", r"$color, $amount",
- (arguments) => _transparentize("fade-out", arguments)),
+ (arguments) => _transparentize("fade-out", arguments))
+ .withDeprecationWarning('color', 'adjust'),
BuiltInCallable.overloadedFunction("alpha", {
- r"$color": (arguments) => switch (arguments[0]) {
- // Support the proprietary Microsoft alpha() function.
- SassString(hasQuotes: false, :var text)
- when text.contains(_microsoftFilterStart) =>
- _functionString("alpha", arguments),
- SassColor(isLegacy: false) => throw SassScriptException(
+ r"$color": (arguments) {
+ switch (arguments[0]) {
+ // Support the proprietary Microsoft alpha() function.
+ case SassString(hasQuotes: false, :var text)
+ when text.contains(_microsoftFilterStart):
+ return _functionString("alpha", arguments);
+ case SassColor(isLegacy: false):
+ throw SassScriptException(
"alpha() is only supported for legacy colors. Please use "
- "color.channel() instead."),
- var argument => SassNumber(argument.assertColor("color").alpha)
- },
+ "color.channel() instead.");
+ case var argument:
+ warnForGlobalBuiltIn('color', 'alpha');
+ return SassNumber(argument.assertColor("color").alpha);
+ }
+ },
r"$args...": (arguments) {
var argList = arguments[0].asList;
if (argList.isNotEmpty &&
@@ -272,6 +291,7 @@ final global = UnmodifiableListView([
return _functionString("opacity", arguments);
}
+ warnForGlobalBuiltIn('color', 'opacity');
var color = arguments[0].assertColor("color");
return SassNumber(color.alpha);
}),
@@ -314,13 +334,13 @@ final global = UnmodifiableListView([
(arguments) => _parseChannels("oklch", arguments[0],
space: ColorSpace.oklch, name: 'channels')),
- _complement,
+ _complement.withDeprecationWarning("color"),
// ### Miscellaneous
_ieHexStr,
- _adjust.withName("adjust-color"),
- _scale.withName("scale-color"),
- _change.withName("change-color")
+ _adjust.withDeprecationWarning('color').withName("adjust-color"),
+ _scale.withDeprecationWarning('color').withName("scale-color"),
+ _change.withDeprecationWarning('color').withName("change-color")
]);
/// The Sass color module.
diff --git a/lib/src/functions/list.dart b/lib/src/functions/list.dart
index 1d911a88d..c0fd0ac2c 100644
--- a/lib/src/functions/list.dart
+++ b/lib/src/functions/list.dart
@@ -13,8 +13,15 @@ import '../value.dart';
/// The global definitions of Sass list functions.
final global = UnmodifiableListView([
- _length, _nth, _setNth, _join, _append, _zip, _index, _isBracketed, //
- _separator.withName("list-separator")
+ _length.withDeprecationWarning('list'),
+ _nth.withDeprecationWarning('list'),
+ _setNth.withDeprecationWarning('list'),
+ _join.withDeprecationWarning('list'),
+ _append.withDeprecationWarning('list'),
+ _zip.withDeprecationWarning('list'),
+ _index.withDeprecationWarning('list'),
+ _isBracketed.withDeprecationWarning('list'),
+ _separator.withDeprecationWarning('list').withName("list-separator")
]);
/// The Sass list module.
diff --git a/lib/src/functions/map.dart b/lib/src/functions/map.dart
index 97628c1fd..099f20a72 100644
--- a/lib/src/functions/map.dart
+++ b/lib/src/functions/map.dart
@@ -15,12 +15,12 @@ import '../value.dart';
/// The global definitions of Sass map functions.
final global = UnmodifiableListView([
- _get.withName("map-get"),
- _merge.withName("map-merge"),
- _remove.withName("map-remove"),
- _keys.withName("map-keys"),
- _values.withName("map-values"),
- _hasKey.withName("map-has-key")
+ _get.withDeprecationWarning('map').withName("map-get"),
+ _merge.withDeprecationWarning('map').withName("map-merge"),
+ _remove.withDeprecationWarning('map').withName("map-remove"),
+ _keys.withDeprecationWarning('map').withName("map-keys"),
+ _values.withDeprecationWarning('map').withName("map-values"),
+ _hasKey.withDeprecationWarning('map').withName("map-has-key")
]);
/// The Sass map module.
@@ -61,7 +61,15 @@ final _set = BuiltInCallable.overloadedFunction("set", {
throw SassScriptException("Expected \$args to contain a value.");
case [...var keys, var value]:
return _modify(map, keys, (_) => value);
- default:
+ default: // ignore: unreachable_switch_default
+ // This code is unreachable, and the compiler knows it (hence the
+ // `unreachable_switch_default` warning being ignored above). However,
+ // due to architectural limitations in the Dart front end, the compiler
+ // doesn't understand that the code is unreachable until late in the
+ // compilation process (after flow analysis). So this `default` clause
+ // must be kept around to avoid flow analysis incorrectly concluding
+ // that the function fails to return. See
+ // https://github.com/dart-lang/language/issues/2977 for details.
throw '[BUG] Unreachable code';
}
},
@@ -87,7 +95,15 @@ final _merge = BuiltInCallable.overloadedFunction("merge", {
if (nestedMap == null) return map2;
return SassMap({...nestedMap.contents, ...map2.contents});
});
- default:
+ default: // ignore: unreachable_switch_default
+ // This code is unreachable, and the compiler knows it (hence the
+ // `unreachable_switch_default` warning being ignored above). However,
+ // due to architectural limitations in the Dart front end, the compiler
+ // doesn't understand that the code is unreachable until late in the
+ // compilation process (after flow analysis). So this `default` clause
+ // must be kept around to avoid flow analysis incorrectly concluding
+ // that the function fails to return. See
+ // https://github.com/dart-lang/language/issues/2977 for details.
throw '[BUG] Unreachable code';
}
},
diff --git a/lib/src/functions/math.dart b/lib/src/functions/math.dart
index bca609d0d..3f3e3bbbe 100644
--- a/lib/src/functions/math.dart
+++ b/lib/src/functions/math.dart
@@ -30,15 +30,23 @@ final global = UnmodifiableListView([
"To emit a CSS abs() now: abs(#{$number})\n"
"More info: https://sass-lang.com/d/abs-percent",
Deprecation.absPercent);
+ } else {
+ warnForGlobalBuiltIn('math', 'abs');
}
return SassNumber.withUnits(number.value.abs(),
numeratorUnits: number.numeratorUnits,
denominatorUnits: number.denominatorUnits);
}),
-
- _ceil, _floor, _max, _min, _percentage, _randomFunction, _round, _unit, //
- _compatible.withName("comparable"),
- _isUnitless.withName("unitless"),
+ _ceil.withDeprecationWarning('math'),
+ _floor.withDeprecationWarning('math'),
+ _max.withDeprecationWarning('math'),
+ _min.withDeprecationWarning('math'),
+ _percentage.withDeprecationWarning('math'),
+ _randomFunction.withDeprecationWarning('math'),
+ _round.withDeprecationWarning('math'),
+ _unit.withDeprecationWarning('math'),
+ _compatible.withDeprecationWarning('math').withName("comparable"),
+ _isUnitless.withDeprecationWarning('math').withName("unitless"),
]);
/// The Sass math module.
diff --git a/lib/src/functions/meta.dart b/lib/src/functions/meta.dart
index 6c7c03e12..03cfe4c87 100644
--- a/lib/src/functions/meta.dart
+++ b/lib/src/functions/meta.dart
@@ -8,6 +8,8 @@ import 'package:collection/collection.dart';
import '../ast/sass/statement/mixin_rule.dart';
import '../callable.dart';
+import '../deprecation.dart';
+import '../evaluation_context.dart';
import '../util/map.dart';
import '../value.dart';
import '../visitor/serialize.dart';
@@ -21,12 +23,20 @@ final _features = {
"custom-property"
};
-/// The global definitions of Sass introspection functions.
-final global = UnmodifiableListView([
+/// Sass introspection functions that exist as both global functions and in the
+/// `sass:meta` module that do not require access to context that's only
+/// available at runtime.
+///
+/// Additional functions are defined in the evaluator.
+final _shared = UnmodifiableListView([
// This is only a partial list of meta functions. The rest are defined in the
// evaluator, because they need access to context that's only available at
// runtime.
_function("feature-exists", r"$feature", (arguments) {
+ warnForDeprecation(
+ "The feature-exists() function is deprecated.\n\n"
+ "More info: https://sass-lang.com/d/feature-exists",
+ Deprecation.featureExists);
var feature = arguments[0].assertString("feature");
return SassBoolean(_features.contains(feature.text));
}),
@@ -56,7 +66,6 @@ final global = UnmodifiableListView([
_ => throw "[BUG] Unknown value type ${arguments[0]}"
},
quotes: false)),
-
_function("keywords", r"$args", (arguments) {
if (arguments[0] case SassArgumentList(:var keywords)) {
return SassMap({
@@ -69,9 +78,14 @@ final global = UnmodifiableListView([
})
]);
-/// The definitions of Sass introspection functions that are only available from
-/// the `sass:meta` module, not as global functions.
-final local = UnmodifiableListView([
+/// The global definitions of Sass introspection functions.
+final global = UnmodifiableListView(
+ [for (var function in _shared) function.withDeprecationWarning('meta')]);
+
+/// The versions of Sass introspection functions defined in the `sass:meta`
+/// module.
+final moduleFunctions = UnmodifiableListView([
+ ..._shared,
_function("calc-name", r"$calc", (arguments) {
var calculation = arguments[0].assertCalculation("calc");
return SassString(calculation.name);
diff --git a/lib/src/functions/selector.dart b/lib/src/functions/selector.dart
index 08e133dbf..7a32af1a2 100644
--- a/lib/src/functions/selector.dart
+++ b/lib/src/functions/selector.dart
@@ -16,14 +16,14 @@ import '../value.dart';
/// The global definitions of Sass selector functions.
final global = UnmodifiableListView([
- _isSuperselector,
- _simpleSelectors,
- _parse.withName("selector-parse"),
- _nest.withName("selector-nest"),
- _append.withName("selector-append"),
- _extend.withName("selector-extend"),
- _replace.withName("selector-replace"),
- _unify.withName("selector-unify")
+ _isSuperselector.withDeprecationWarning('selector'),
+ _simpleSelectors.withDeprecationWarning('selector'),
+ _parse.withDeprecationWarning('selector').withName("selector-parse"),
+ _nest.withDeprecationWarning('selector').withName("selector-nest"),
+ _append.withDeprecationWarning('selector').withName("selector-append"),
+ _extend.withDeprecationWarning('selector').withName("selector-extend"),
+ _replace.withDeprecationWarning('selector').withName("selector-replace"),
+ _unify.withDeprecationWarning('selector').withName("selector-unify")
]);
/// The Sass selector module.
diff --git a/lib/src/functions/string.dart b/lib/src/functions/string.dart
index 99d1ee6d2..100337de9 100644
--- a/lib/src/functions/string.dart
+++ b/lib/src/functions/string.dart
@@ -22,11 +22,15 @@ var _previousUniqueId = _random.nextInt(math.pow(36, 6) as int);
/// The global definitions of Sass string functions.
final global = UnmodifiableListView([
- _unquote, _quote, _toUpperCase, _toLowerCase, _uniqueId, //
- _length.withName("str-length"),
- _insert.withName("str-insert"),
- _index.withName("str-index"),
- _slice.withName("str-slice")
+ _unquote.withDeprecationWarning('string'),
+ _quote.withDeprecationWarning('string'),
+ _toUpperCase.withDeprecationWarning('string'),
+ _toLowerCase.withDeprecationWarning('string'),
+ _uniqueId.withDeprecationWarning('string'),
+ _length.withDeprecationWarning('string').withName("str-length"),
+ _insert.withDeprecationWarning('string').withName("str-insert"),
+ _index.withDeprecationWarning('string').withName("str-index"),
+ _slice.withDeprecationWarning('string').withName("str-slice")
]);
/// The Sass string module.
diff --git a/lib/src/import_cache.dart b/lib/src/import_cache.dart
index 6b46bce21..f718e5d40 100644
--- a/lib/src/import_cache.dart
+++ b/lib/src/import_cache.dart
@@ -5,7 +5,7 @@
// DO NOT EDIT. This file was generated from async_import_cache.dart.
// See tool/grind/synchronize.dart for details.
//
-// Checksum: f70eea612e1613ef93bad353803ad9479cda04aa
+// Checksum: e043f2a3dafb741a6f053a7c1785b1c9959242f5
//
// ignore_for_file: unused_import
@@ -23,6 +23,7 @@ import 'importer/no_op.dart';
import 'importer/utils.dart';
import 'io.dart';
import 'logger.dart';
+import 'logger/deprecation_processing.dart';
import 'util/map.dart';
import 'util/nullable.dart';
import 'utils.dart';
@@ -63,7 +64,7 @@ final class ImportCache {
<(Importer, Uri, {bool forImport}), CanonicalizeResult?>{};
/// A map from the keys in [_perImporterCanonicalizeCache] that are generated
- /// for relative URL loads agains the base importer to the original relative
+ /// for relative URL loads against the base importer to the original relative
/// URLs what were loaded.
///
/// This is used to invalidate the cache when files are changed.
@@ -98,18 +99,18 @@ final class ImportCache {
PackageConfig? packageConfig,
Logger? logger})
: _importers = _toImporters(importers, loadPaths, packageConfig),
- _logger = logger ?? const Logger.stderr();
+ _logger = logger ?? Logger.stderr();
/// Creates an import cache without any globally-available importers.
ImportCache.none({Logger? logger})
: _importers = const [],
- _logger = logger ?? const Logger.stderr();
+ _logger = logger ?? Logger.stderr();
/// Creates an import cache without any globally-available importers, and only
/// the passed in importers.
ImportCache.only(Iterable importers, {Logger? logger})
: _importers = List.unmodifiable(importers),
- _logger = logger ?? const Logger.stderr();
+ _logger = logger ?? Logger.stderr();
/// Converts the user's [importers], [loadPaths], and [packageConfig]
/// options into a single list of importers.
@@ -171,11 +172,11 @@ final class ImportCache {
var key = (url, forImport: forImport);
if (_canonicalizeCache.containsKey(key)) return _canonicalizeCache[key];
- // Each indivudal call to a `canonicalize()` override may not be cacheable
+ // Each individual call to a `canonicalize()` override may not be cacheable
// (specifically, if it has access to `containingUrl` it's too
// context-sensitive to usefully cache). We want to cache a given URL across
// the _entire_ importer chain, so we use [cacheable] to track whether _all_
- // `canonicalize()` calls we've attempted are cacheable. Only if they are do
+ // `canonicalize()` calls we've attempted are cacheable. Only if they are, do
// we store the result in the cache.
var cacheable = true;
for (var i = 0; i < _importers.length; i++) {
@@ -355,4 +356,25 @@ final class ImportCache {
_resultsCache.remove(canonicalUrl);
_importCache.remove(canonicalUrl);
}
+
+ /// Wraps [logger] to process deprecations within an ImportCache.
+ ///
+ /// This wrapped logger will handle the deprecation options, but will not
+ /// limit repetition, as it can be re-used across parses. A logger passed to
+ /// an ImportCache or AsyncImportCache should generally be wrapped here first,
+ /// unless it's already been wrapped to process deprecations, in which case
+ /// this method has no effect.
+ static DeprecationProcessingLogger wrapLogger(
+ Logger? logger,
+ Iterable? silenceDeprecations,
+ Iterable? fatalDeprecations,
+ Iterable? futureDeprecations,
+ {bool color = false}) {
+ if (logger is DeprecationProcessingLogger) return logger;
+ return DeprecationProcessingLogger(logger ?? Logger.stderr(color: color),
+ silenceDeprecations: {...?silenceDeprecations},
+ fatalDeprecations: {...?fatalDeprecations},
+ futureDeprecations: {...?futureDeprecations},
+ limitRepetition: false);
+ }
}
diff --git a/lib/src/importer/filesystem.dart b/lib/src/importer/filesystem.dart
index cb23d3095..0c57c82d8 100644
--- a/lib/src/importer/filesystem.dart
+++ b/lib/src/importer/filesystem.dart
@@ -60,7 +60,7 @@ class FilesystemImporter extends Importer {
"Use FilesystemImporter.noLoadPath or FilesystemImporter('.') instead.")
static final cwd = FilesystemImporter._deprecated('.');
- /// Creates an importer that _only_ loads absolute `file:` URLsand URLs
+ /// Creates an importer that _only_ loads absolute `file:` URLs and URLs
/// relative to the current file.
static final noLoadPath = FilesystemImporter._noLoadPath();
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/compile.dart b/lib/src/js/compile.dart
index ab5c57cc0..50016334e 100644
--- a/lib/src/js/compile.dart
+++ b/lib/src/js/compile.dart
@@ -7,9 +7,8 @@ import 'package:node_interop/js.dart';
import 'package:node_interop/util.dart' hide futureToPromise;
import 'package:term_glyph/term_glyph.dart' as glyph;
import 'package:path/path.dart' as p;
-import 'package:pub_semver/pub_semver.dart';
-import '../../sass.dart';
+import '../../sass.dart' hide Deprecation;
import '../importer/no_op.dart';
import '../importer/js_to_dart/async.dart';
import '../importer/js_to_dart/async_file.dart';
@@ -21,7 +20,7 @@ import '../logger/js_to_dart.dart';
import '../util/nullable.dart';
import 'compile_options.dart';
import 'compile_result.dart';
-import 'deprecations.dart' as js show Deprecation;
+import 'deprecations.dart';
import 'exception.dart';
import 'importer.dart';
import 'reflection.dart';
@@ -51,12 +50,12 @@ NodeCompileResult compile(String path, [CompileOptions? options]) {
logger: logger,
importers: options?.importers?.map(_parseImporter),
functions: _parseFunctions(options?.functions).cast(),
- fatalDeprecations: _parseDeprecations(
- logger, options?.fatalDeprecations, supportVersions: true),
+ fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations,
+ supportVersions: true),
silenceDeprecations:
- _parseDeprecations(logger, options?.silenceDeprecations),
+ parseDeprecations(logger, options?.silenceDeprecations),
futureDeprecations:
- _parseDeprecations(logger, options?.futureDeprecations));
+ parseDeprecations(logger, options?.futureDeprecations));
return _convertResult(result,
includeSourceContents: options?.sourceMapIncludeSources ?? false);
} on SassException catch (error, stackTrace) {
@@ -89,12 +88,12 @@ NodeCompileResult compileString(String text, [CompileStringOptions? options]) {
importer: options?.importer.andThen(_parseImporter) ??
(options?.url == null ? NoOpImporter() : null),
functions: _parseFunctions(options?.functions).cast(),
- fatalDeprecations: _parseDeprecations(
- logger, options?.fatalDeprecations, supportVersions: true),
+ fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations,
+ supportVersions: true),
silenceDeprecations:
- _parseDeprecations(logger, options?.silenceDeprecations),
+ parseDeprecations(logger, options?.silenceDeprecations),
futureDeprecations:
- _parseDeprecations(logger, options?.futureDeprecations));
+ parseDeprecations(logger, options?.futureDeprecations));
return _convertResult(result,
includeSourceContents: options?.sourceMapIncludeSources ?? false);
} on SassException catch (error, stackTrace) {
@@ -127,12 +126,12 @@ Promise compileAsync(String path, [CompileOptions? options]) {
importers: options?.importers
?.map((importer) => _parseAsyncImporter(importer)),
functions: _parseFunctions(options?.functions, asynch: true),
- fatalDeprecations: _parseDeprecations(
- logger, options?.fatalDeprecations, supportVersions: true),
+ fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations,
+ supportVersions: true),
silenceDeprecations:
- _parseDeprecations(logger, options?.silenceDeprecations),
+ parseDeprecations(logger, options?.silenceDeprecations),
futureDeprecations:
- _parseDeprecations(logger, options?.futureDeprecations));
+ parseDeprecations(logger, options?.futureDeprecations));
return _convertResult(result,
includeSourceContents: options?.sourceMapIncludeSources ?? false);
}()), color: color, ascii: ascii);
@@ -165,12 +164,12 @@ Promise compileStringAsync(String text, [CompileStringOptions? options]) {
.andThen((importer) => _parseAsyncImporter(importer)) ??
(options?.url == null ? NoOpImporter() : null),
functions: _parseFunctions(options?.functions, asynch: true),
- fatalDeprecations: _parseDeprecations(
- logger, options?.fatalDeprecations, supportVersions: true),
+ fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations,
+ supportVersions: true),
silenceDeprecations:
- _parseDeprecations(logger, options?.silenceDeprecations),
+ parseDeprecations(logger, options?.silenceDeprecations),
futureDeprecations:
- _parseDeprecations(logger, options?.futureDeprecations));
+ parseDeprecations(logger, options?.futureDeprecations));
return _convertResult(result,
includeSourceContents: options?.sourceMapIncludeSources ?? false);
}()), color: color, ascii: ascii);
@@ -359,39 +358,6 @@ List _parseFunctions(Object? functions, {bool asynch = false}) {
return result;
}
-/// Parses a list of [deprecations] from JS into an list of Dart [Deprecation]
-/// objects.
-///
-/// [deprecations] can contain deprecation IDs, JS Deprecation objects, and
-/// (if [supportVersions] is true) [Version]s.
-Iterable? _parseDeprecations(
- JSToDartLogger logger, List? deprecations,
- {bool supportVersions = false}) {
- if (deprecations == null) return null;
- return () sync* {
- for (var item in deprecations) {
- switch (item) {
- case String id:
- var deprecation = Deprecation.fromId(id);
- if (deprecation == null) {
- logger.warn('Invalid deprecation "$id".');
- } else {
- yield deprecation;
- }
- case js.Deprecation(:var id):
- var deprecation = Deprecation.fromId(id);
- if (deprecation == null) {
- logger.warn('Invalid deprecation "$id".');
- } else {
- yield deprecation;
- }
- case Version version when supportVersions:
- yield* Deprecation.forVersion(version);
- }
- }
- }();
-}
-
/// The exported `NodePackageImporter` class that can be added to the
/// `importers` option to enable loading `pkg:` URLs from `node_modules`.
final JSClass nodePackageImporterClass = () {
diff --git a/lib/src/js/deprecations.dart b/lib/src/js/deprecations.dart
index 51e3aec1a..e26fe9ea3 100644
--- a/lib/src/js/deprecations.dart
+++ b/lib/src/js/deprecations.dart
@@ -6,6 +6,7 @@ import 'package:js/js.dart';
import 'package:pub_semver/pub_semver.dart';
import '../deprecation.dart' as dart show Deprecation;
+import '../logger/js_to_dart.dart';
import 'reflection.dart';
@JS()
@@ -44,6 +45,39 @@ final Map deprecations = {
obsoleteIn: deprecation.deprecatedIn),
};
+/// Parses a list of [deprecations] from JS into an list of Dart [Deprecation]
+/// objects.
+///
+/// [deprecations] can contain deprecation IDs, JS Deprecation objects, and
+/// (if [supportVersions] is true) [Version]s.
+Iterable? parseDeprecations(
+ JSToDartLogger logger, List? deprecations,
+ {bool supportVersions = false}) {
+ if (deprecations == null) return null;
+ return () sync* {
+ for (var item in deprecations) {
+ switch (item) {
+ case String id:
+ var deprecation = dart.Deprecation.fromId(id);
+ if (deprecation == null) {
+ logger.warn('Invalid deprecation "$id".');
+ } else {
+ yield deprecation;
+ }
+ case Deprecation(:var id):
+ var deprecation = dart.Deprecation.fromId(id);
+ if (deprecation == null) {
+ logger.warn('Invalid deprecation "$id".');
+ } else {
+ yield deprecation;
+ }
+ case Version version when supportVersions:
+ yield* dart.Deprecation.forVersion(version);
+ }
+ }
+ }();
+}
+
/// The JavaScript `Version` class.
final JSClass versionClass = () {
var jsClass = createJSClass('sass.Version',
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/legacy.dart b/lib/src/js/legacy.dart
index ed4ba7584..65ae4ca48 100644
--- a/lib/src/js/legacy.dart
+++ b/lib/src/js/legacy.dart
@@ -27,6 +27,7 @@ import '../util/nullable.dart';
import '../utils.dart';
import '../value.dart';
import '../visitor/serialize.dart';
+import 'deprecations.dart';
import 'function.dart';
import 'legacy/render_context.dart';
import 'legacy/render_options.dart';
@@ -76,6 +77,8 @@ Future _renderAsync(RenderOptions options) async {
CompileResult result;
var file = options.file.andThen(p.absolute);
+ var logger =
+ JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal));
if (options.data case var data?) {
result = await compileStringAsync(data,
nodeImporter: _parseImporter(options, start),
@@ -88,11 +91,16 @@ Future _renderAsync(RenderOptions options) async {
lineFeed: _parseLineFeed(options.linefeed),
url: file == null ? 'stdin' : p.toUri(file).toString(),
quietDeps: options.quietDeps ?? false,
+ fatalDeprecations: parseDeprecations(logger, options.fatalDeprecations,
+ supportVersions: true),
+ futureDeprecations:
+ parseDeprecations(logger, options.futureDeprecations),
+ silenceDeprecations:
+ parseDeprecations(logger, options.silenceDeprecations),
verbose: options.verbose ?? false,
charset: options.charset ?? true,
sourceMap: _enableSourceMaps(options),
- logger:
- JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal)));
+ logger: logger);
} else if (file != null) {
result = await compileAsync(file,
nodeImporter: _parseImporter(options, start),
@@ -104,11 +112,16 @@ Future _renderAsync(RenderOptions options) async {
indentWidth: _parseIndentWidth(options.indentWidth),
lineFeed: _parseLineFeed(options.linefeed),
quietDeps: options.quietDeps ?? false,
+ fatalDeprecations: parseDeprecations(logger, options.fatalDeprecations,
+ supportVersions: true),
+ futureDeprecations:
+ parseDeprecations(logger, options.futureDeprecations),
+ silenceDeprecations:
+ parseDeprecations(logger, options.silenceDeprecations),
verbose: options.verbose ?? false,
charset: options.charset ?? true,
sourceMap: _enableSourceMaps(options),
- logger:
- JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal)));
+ logger: logger);
} else {
throw ArgumentError("Either options.data or options.file must be set.");
}
@@ -131,6 +144,8 @@ RenderResult renderSync(RenderOptions options) {
CompileResult result;
var file = options.file.andThen(p.absolute);
+ var logger =
+ JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal));
if (options.data case var data?) {
result = compileString(data,
nodeImporter: _parseImporter(options, start),
@@ -143,11 +158,16 @@ RenderResult renderSync(RenderOptions options) {
lineFeed: _parseLineFeed(options.linefeed),
url: file == null ? 'stdin' : p.toUri(file).toString(),
quietDeps: options.quietDeps ?? false,
+ fatalDeprecations: parseDeprecations(
+ logger, options.fatalDeprecations, supportVersions: true),
+ futureDeprecations:
+ parseDeprecations(logger, options.futureDeprecations),
+ silenceDeprecations:
+ parseDeprecations(logger, options.silenceDeprecations),
verbose: options.verbose ?? false,
charset: options.charset ?? true,
sourceMap: _enableSourceMaps(options),
- logger: JSToDartLogger(
- options.logger, Logger.stderr(color: hasTerminal)));
+ logger: logger);
} else if (file != null) {
result = compile(file,
nodeImporter: _parseImporter(options, start),
@@ -159,11 +179,16 @@ RenderResult renderSync(RenderOptions options) {
indentWidth: _parseIndentWidth(options.indentWidth),
lineFeed: _parseLineFeed(options.linefeed),
quietDeps: options.quietDeps ?? false,
+ fatalDeprecations: parseDeprecations(
+ logger, options.fatalDeprecations, supportVersions: true),
+ futureDeprecations:
+ parseDeprecations(logger, options.futureDeprecations),
+ silenceDeprecations:
+ parseDeprecations(logger, options.silenceDeprecations),
verbose: options.verbose ?? false,
charset: options.charset ?? true,
sourceMap: _enableSourceMaps(options),
- logger: JSToDartLogger(
- options.logger, Logger.stderr(color: hasTerminal)));
+ logger: logger);
} else {
throw ArgumentError("Either options.data or options.file must be set.");
}
diff --git a/lib/src/js/legacy/render_options.dart b/lib/src/js/legacy/render_options.dart
index ac8cc61b8..e63de45f1 100644
--- a/lib/src/js/legacy/render_options.dart
+++ b/lib/src/js/legacy/render_options.dart
@@ -30,6 +30,9 @@ class RenderOptions {
external bool? get sourceMapEmbed;
external String? get sourceMapRoot;
external bool? get quietDeps;
+ external List? get fatalDeprecations;
+ external List? get futureDeprecations;
+ external List? get silenceDeprecations;
external bool? get verbose;
external bool? get charset;
external JSLogger? get logger;
@@ -54,6 +57,9 @@ class RenderOptions {
bool? sourceMapEmbed,
String? sourceMapRoot,
bool? quietDeps,
+ List? fatalDeprecations,
+ List? futureDeprecations,
+ List? silenceDeprecations,
bool? verbose,
bool? charset,
JSLogger? logger});
diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart
new file mode 100644
index 000000000..7dbe81c81
--- /dev/null
+++ b/lib/src/js/parser.dart
@@ -0,0 +1,96 @@
+// 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));
+ getJSClass(file)
+ .defineGetter('codeUnits', (SourceFile self) => self.codeUnits);
+ 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/source_span.dart b/lib/src/js/source_span.dart
index 057aec500..ddf8ee776 100644
--- a/lib/src/js/source_span.dart
+++ b/lib/src/js/source_span.dart
@@ -2,6 +2,7 @@
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
+import 'package:path/path.dart' as p;
import 'package:source_span/source_span.dart';
import '../util/lazy_file_span.dart';
@@ -21,7 +22,8 @@ void updateSourceSpanPrototype() {
getJSClass(item).defineGetters({
'start': (FileSpan span) => span.start,
'end': (FileSpan span) => span.end,
- 'url': (FileSpan span) => span.sourceUrl.andThen(dartToJSUrl),
+ 'url': (FileSpan span) => span.sourceUrl.andThen((url) => dartToJSUrl(
+ url.scheme == '' ? p.toUri(p.absolute(p.fromUri(url))) : url)),
'text': (FileSpan span) => span.text,
'context': (FileSpan span) => span.context,
});
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/lib/src/logger/deprecation_processing.dart b/lib/src/logger/deprecation_processing.dart
index 37bd4464f..d83c2ef80 100644
--- a/lib/src/logger/deprecation_processing.dart
+++ b/lib/src/logger/deprecation_processing.dart
@@ -45,7 +45,10 @@ final class DeprecationProcessingLogger extends LoggerWithDeprecationType {
{required this.silenceDeprecations,
required this.fatalDeprecations,
required this.futureDeprecations,
- this.limitRepetition = true}) {
+ this.limitRepetition = true});
+
+ /// Warns if any of the deprecations options are incompatible or unnecessary.
+ void validate() {
for (var deprecation in fatalDeprecations) {
switch (deprecation) {
case Deprecation(isFuture: true)
diff --git a/lib/src/parse/sass.dart b/lib/src/parse/sass.dart
index 78dea3179..689131bf1 100644
--- a/lib/src/parse/sass.dart
+++ b/lib/src/parse/sass.dart
@@ -268,7 +268,6 @@ class SassParser extends StylesheetParser {
_readIndentation();
}
- if (!buffer.trailingString.trimRight().endsWith("*/")) buffer.write(" */");
return LoudComment(buffer.interpolation(scanner.spanFrom(start)));
}
diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart
index 361727c1e..b619949b0 100644
--- a/lib/src/parse/stylesheet.dart
+++ b/lib/src/parse/stylesheet.dart
@@ -736,7 +736,7 @@ abstract class StylesheetParser extends Parser {
whitespace();
return _withChildren(_statement, start,
(children, span) => AtRootRule(children, span, query: query));
- } else if (lookingAtChildren()) {
+ } else if (lookingAtChildren() || (indented && atEndOfStatement())) {
return _withChildren(
_statement, start, (children, span) => AtRootRule(children, span));
} else {
@@ -753,12 +753,12 @@ abstract class StylesheetParser extends Parser {
buffer.writeCharCode($lparen);
whitespace();
- buffer.add(_expression());
+ _addOrInject(buffer, _expression());
if (scanner.scanChar($colon)) {
whitespace();
buffer.writeCharCode($colon);
buffer.writeCharCode($space);
- buffer.add(_expression());
+ _addOrInject(buffer, _expression());
}
scanner.expectChar($rparen);
@@ -2850,7 +2850,7 @@ abstract class StylesheetParser extends Parser {
///
/// If [allowColon] is `false`, this stops at top-level colons.
///
- /// If [allowOpenBrace] is `false`, this stops at top-level colons.
+ /// If [allowOpenBrace] is `false`, this stops at opening curly braces.
///
/// If [silentComments] is `true`, this will parse silent comments as
/// comments. Otherwise, it will preserve two adjacent slashes and emit them
@@ -2897,10 +2897,6 @@ abstract class StylesheetParser extends Parser {
wroteNewline = false;
}
- case $slash when silentComments && scanner.peekChar(1) == $slash:
- buffer.write(rawText(loudComment));
- wroteNewline = false;
-
// Add a full interpolated identifier to handle cases like "#{...}--1",
// since "--1" isn't a valid identifier on its own.
case $hash when scanner.peekChar(1) == $lbrace:
@@ -3528,6 +3524,16 @@ abstract class StylesheetParser extends Parser {
span());
}
+ /// Adds [expression] to [buffer], or if it's an unquoted string adds the
+ /// interpolation it contains instead.
+ void _addOrInject(InterpolationBuffer buffer, Expression expression) {
+ if (expression is StringExpression && !expression.hasQuotes) {
+ buffer.addInterpolation(expression.text);
+ } else {
+ buffer.add(expression);
+ }
+ }
+
// ## Abstract Methods
/// Whether this is parsing the indented syntax.
diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart
index 7d1a489af..eabadbc52 100644
--- a/lib/src/visitor/async_evaluate.dart
+++ b/lib/src/visitor/async_evaluate.dart
@@ -576,14 +576,21 @@ final class _EvaluateVisitor
];
var metaModule = BuiltInModule("meta",
- functions: [...meta.global, ...meta.local, ...metaFunctions],
+ functions: [...meta.moduleFunctions, ...metaFunctions],
mixins: metaMixins);
for (var module in [...coreModules, metaModule]) {
_builtInModules[module.url] = module;
}
- functions = [...?functions, ...globalFunctions, ...metaFunctions];
+ functions = [
+ ...?functions,
+ ...globalFunctions,
+ ...[
+ for (var function in metaFunctions)
+ function.withDeprecationWarning('meta')
+ ]
+ ];
for (var function in functions) {
_builtInFunctions[function.name.replaceAll("_", "-")] = function;
}
@@ -1907,8 +1914,10 @@ final class _EvaluateVisitor
_endOfImports++;
}
- _parent.addChild(ModifiableCssComment(
- await _performInterpolation(node.text), node.span));
+ var text = await _performInterpolation(node.text);
+ // Indented syntax doesn't require */
+ if (!text.endsWith("*/")) text += " */";
+ _parent.addChild(ModifiableCssComment(text, node.span));
return null;
}
diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart
index 36bb0477e..1aca4eee6 100644
--- a/lib/src/visitor/evaluate.dart
+++ b/lib/src/visitor/evaluate.dart
@@ -5,7 +5,7 @@
// DO NOT EDIT. This file was generated from async_evaluate.dart.
// See tool/grind/synchronize.dart for details.
//
-// Checksum: b40f3e7568d9e987dde6633424073afdc6a69349
+// Checksum: de25b9055a73f1c7ebe7a707139e6c789a2866dd
//
// ignore_for_file: unused_import
@@ -578,14 +578,21 @@ final class _EvaluateVisitor
];
var metaModule = BuiltInModule("meta",
- functions: [...meta.global, ...meta.local, ...metaFunctions],
+ functions: [...meta.moduleFunctions, ...metaFunctions],
mixins: metaMixins);
for (var module in [...coreModules, metaModule]) {
_builtInModules[module.url] = module;
}
- functions = [...?functions, ...globalFunctions, ...metaFunctions];
+ functions = [
+ ...?functions,
+ ...globalFunctions,
+ ...[
+ for (var function in metaFunctions)
+ function.withDeprecationWarning('meta')
+ ]
+ ];
for (var function in functions) {
_builtInFunctions[function.name.replaceAll("_", "-")] = function;
}
@@ -1899,8 +1906,10 @@ final class _EvaluateVisitor
_endOfImports++;
}
- _parent.addChild(
- ModifiableCssComment(_performInterpolation(node.text), node.span));
+ var text = _performInterpolation(node.text);
+ // Indented syntax doesn't require */
+ if (!text.endsWith("*/")) text += " */";
+ _parent.addChild(ModifiableCssComment(text, node.span));
return null;
}
diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart
index dec68b275..4d036e6e3 100644
--- a/lib/src/visitor/serialize.dart
+++ b/lib/src/visitor/serialize.dart
@@ -1212,7 +1212,7 @@ final class _SerializeVisitor
if (negative) textIndex++;
while (true) {
if (textIndex == text.length) {
- // If we get here, [text] has no decmial point. It definitely doesn't
+ // If we get here, [text] has no decimal point. It definitely doesn't
// need to be rounded; we can write it as-is.
buffer.write(text);
return;
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..89908a492
--- /dev/null
+++ b/pkg/sass-parser/README.md
@@ -0,0 +1,259 @@
+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!"');
+```
+
+### Known Incompatibilities
+
+There are a few cases where an operation that's valid in PostCSS won't work with
+`sass-parser`:
+
+* Trying to convert a Sass-specific at-rule like `@if` or `@mixin` into a
+ different at-rule by changing its name is not supported.
+
+* Trying to add child nodes to a Sass statement that doesn't support children
+ like `@use` or `@error` is not supported.
diff --git a/pkg/sass-parser/jest.config.ts b/pkg/sass-parser/jest.config.ts
new file mode 100644
index 000000000..d7cc13f80
--- /dev/null
+++ b/pkg/sass-parser/jest.config.ts
@@ -0,0 +1,9 @@
+const config = {
+ preset: 'ts-jest',
+ roots: ['lib'],
+ testEnvironment: 'node',
+ setupFilesAfterEnv: ['jest-extended/all', '/test/setup.ts'],
+ verbose: false,
+};
+
+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..057f7881d
--- /dev/null
+++ b/pkg/sass-parser/lib/index.ts
@@ -0,0 +1,124 @@
+// 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 {
+ CssComment,
+ CssCommentProps,
+ CssCommentRaws,
+} from './src/statement/css-comment';
+export {
+ DebugRule,
+ DebugRuleProps,
+ DebugRuleRaws,
+} from './src/statement/debug-rule';
+export {EachRule, EachRuleProps, EachRuleRaws} from './src/statement/each-rule';
+export {
+ ErrorRule,
+ ErrorRuleProps,
+ ErrorRuleRaws,
+} from './src/statement/error-rule';
+export {ForRule, ForRuleProps, ForRuleRaws} from './src/statement/for-rule';
+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 {
+ SassComment,
+ SassCommentProps,
+ SassCommentRaws,
+} from './src/statement/sass-comment';
+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..8ffbb94a2
--- /dev/null
+++ b/pkg/sass-parser/lib/src/interpolation.ts
@@ -0,0 +1,421 @@
+// 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.some(node => typeof node !== 'string')) return null;
+ return this.nodes.join('');
+ }
+
+ /**
+ * 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..9d9783fe2
--- /dev/null
+++ b/pkg/sass-parser/lib/src/postcss.d.ts
@@ -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.
+
+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..4f18be58b
--- /dev/null
+++ b/pkg/sass-parser/lib/src/sass-internal.ts
@@ -0,0 +1,191 @@
+// 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;
+
+ readonly codeUnits: number[];
+
+ 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 AtRootRule extends ParentStatement {
+ readonly name: Interpolation;
+ readonly query?: Interpolation;
+ }
+
+ class AtRule extends ParentStatement {
+ readonly name: Interpolation;
+ readonly value?: Interpolation;
+ }
+
+ class DebugRule extends Statement {
+ readonly expression: Expression;
+ }
+
+ class EachRule extends ParentStatement {
+ readonly variables: string[];
+ readonly list: Expression;
+ }
+
+ class ErrorRule extends Statement {
+ readonly expression: Expression;
+ }
+
+ class ExtendRule extends Statement {
+ readonly selector: Interpolation;
+ readonly isOptional: boolean;
+ }
+
+ class ForRule extends ParentStatement {
+ readonly variable: string;
+ readonly from: Expression;
+ readonly to: Expression;
+ readonly isExclusive: boolean;
+ }
+
+ class LoudComment extends Statement {
+ readonly text: Interpolation;
+ }
+
+ class MediaRule extends ParentStatement {
+ readonly query: Interpolation;
+ }
+
+ class SilentComment extends Statement {
+ readonly text: string;
+ }
+
+ 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 AtRootRule = SassInternal.AtRootRule;
+export type AtRule = SassInternal.AtRule;
+export type DebugRule = SassInternal.DebugRule;
+export type EachRule = SassInternal.EachRule;
+export type ErrorRule = SassInternal.ErrorRule;
+export type ExtendRule = SassInternal.ExtendRule;
+export type ForRule = SassInternal.ForRule;
+export type LoudComment = SassInternal.LoudComment;
+export type MediaRule = SassInternal.MediaRule;
+export type SilentComment = SassInternal.SilentComment;
+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 {
+ visitAtRootRule(node: AtRootRule): T;
+ visitAtRule(node: AtRule): T;
+ visitDebugRule(node: DebugRule): T;
+ visitEachRule(node: EachRule): T;
+ visitErrorRule(node: ErrorRule): T;
+ visitExtendRule(node: ExtendRule): T;
+ visitForRule(node: ForRule): T;
+ visitLoudComment(node: LoudComment): T;
+ visitMediaRule(node: MediaRule): T;
+ visitSilentComment(node: SilentComment): 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__/css-comment.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap
new file mode 100644
index 000000000..1e19f31d6
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`a CSS-style comment toJSON 1`] = `
+{
+ "inputs": [
+ {
+ "css": "/* foo */",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "raws": {
+ "closed": true,
+ "left": " ",
+ "right": " ",
+ },
+ "sassType": "comment",
+ "source": <1:1-1:10 in 0>,
+ "text": "foo",
+ "textInterpolation": ,
+ "type": "comment",
+}
+`;
diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap
new file mode 100644
index 000000000..621628a23
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap
@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`a @debug rule toJSON 1`] = `
+{
+ "debugExpression": ,
+ "inputs": [
+ {
+ "css": "@debug foo",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "name": "debug",
+ "params": "foo",
+ "raws": {},
+ "sassType": "debug-rule",
+ "source": <1:1-1:11 in 0>,
+ "type": "atrule",
+}
+`;
diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap
new file mode 100644
index 000000000..75dd15404
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`an @each rule toJSON 1`] = `
+{
+ "eachExpression": ,
+ "inputs": [
+ {
+ "css": "@each $foo, $bar in baz {}",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "name": "each",
+ "nodes": [],
+ "params": "$foo, $bar in baz",
+ "raws": {},
+ "sassType": "each-rule",
+ "source": <1:1-1:27 in 0>,
+ "type": "atrule",
+ "variables": [
+ "foo",
+ "bar",
+ ],
+}
+`;
diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap
new file mode 100644
index 000000000..9ed3f5667
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap
@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`a @error rule toJSON 1`] = `
+{
+ "errorExpression": ,
+ "inputs": [
+ {
+ "css": "@error foo",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "name": "error",
+ "params": "foo",
+ "raws": {},
+ "sassType": "error-rule",
+ "source": <1:1-1:11 in 0>,
+ "type": "atrule",
+}
+`;
diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap
new file mode 100644
index 000000000..f96b0007f
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap
@@ -0,0 +1,24 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`an @for rule toJSON 1`] = `
+{
+ "fromExpression": ,
+ "inputs": [
+ {
+ "css": "@for $foo from bar to baz {}",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "name": "for",
+ "nodes": [],
+ "params": "$foo from bar to baz",
+ "raws": {},
+ "sassType": "for-rule",
+ "source": <1:1-1:29 in 0>,
+ "to": "to",
+ "toExpression": ,
+ "type": "atrule",
+ "variable": "foo",
+}
+`;
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/__snapshots__/sass-comment.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap
new file mode 100644
index 000000000..dc289b9ae
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap
@@ -0,0 +1,24 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`a Sass-style comment toJSON 1`] = `
+{
+ "inputs": [
+ {
+ "css": "// foo",
+ "hasBOM": false,
+ "id": " ",
+ },
+ ],
+ "raws": {
+ "before": "",
+ "beforeLines": [
+ "",
+ ],
+ "left": " ",
+ },
+ "sassType": "sass-comment",
+ "source": <1:1-1:7 in 0>,
+ "text": "foo",
+ "type": "comment",
+}
+`;
diff --git a/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts
new file mode 100644
index 000000000..b5c7a17f7
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts
@@ -0,0 +1,140 @@
+// 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, Rule, scss} from '../..';
+
+describe('an @at-root rule', () => {
+ let node: GenericAtRule;
+
+ describe('with no params', () => {
+ beforeEach(
+ () => void (node = scss.parse('@at-root {}').nodes[0] as GenericAtRule)
+ );
+
+ it('has a name', () => expect(node.name).toBe('at-root'));
+
+ it('has no paramsInterpolation', () =>
+ expect(node.paramsInterpolation).toBeUndefined());
+
+ it('has no params', () => expect(node.params).toBe(''));
+ });
+
+ describe('with no interpolation', () => {
+ beforeEach(
+ () =>
+ void (node = scss.parse('@at-root (with: rule) {}')
+ .nodes[0] as GenericAtRule)
+ );
+
+ it('has a name', () => expect(node.name).toBe('at-root'));
+
+ it('has a paramsInterpolation', () =>
+ expect(node).toHaveInterpolation('paramsInterpolation', '(with: rule)'));
+
+ it('has matching params', () => expect(node.params).toBe('(with: rule)'));
+ });
+
+ // TODO: test a variable used directly without interpolation
+
+ describe('with interpolation', () => {
+ beforeEach(
+ () =>
+ void (node = scss.parse('@at-root (with: #{rule}) {}')
+ .nodes[0] as GenericAtRule)
+ );
+
+ it('has a name', () => expect(node.name).toBe('at-root'));
+
+ it('has a paramsInterpolation', () => {
+ const params = node.paramsInterpolation!;
+ expect(params.nodes[0]).toBe('(with: ');
+ expect(params).toHaveStringExpression(1, 'rule');
+ expect(params.nodes[2]).toBe(')');
+ });
+
+ it('has matching params', () =>
+ expect(node.params).toBe('(with: #{rule})'));
+ });
+
+ describe('with style rule shorthand', () => {
+ beforeEach(
+ () =>
+ void (node = scss.parse('@at-root .foo {}').nodes[0] as GenericAtRule)
+ );
+
+ it('has a name', () => expect(node.name).toBe('at-root'));
+
+ it('has no paramsInterpolation', () =>
+ expect(node.paramsInterpolation).toBeUndefined());
+
+ it('has no params', () => expect(node.params).toBe(''));
+
+ it('contains a Rule', () => {
+ const rule = node.nodes[0] as Rule;
+ expect(rule).toHaveInterpolation('selectorInterpolation', '.foo ');
+ expect(rule.parent).toBe(node);
+ });
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ it('with atRootShorthand: false', () =>
+ expect(
+ new GenericAtRule({
+ name: 'at-root',
+ nodes: [{selector: '.foo'}],
+ raws: {atRootShorthand: false},
+ }).toString()
+ ).toBe('@at-root {\n .foo {}\n}'));
+
+ describe('with atRootShorthand: true', () => {
+ it('with no params and only a style rule child', () =>
+ expect(
+ new GenericAtRule({
+ name: 'at-root',
+ nodes: [{selector: '.foo'}],
+ raws: {atRootShorthand: true},
+ }).toString()
+ ).toBe('@at-root .foo {}'));
+
+ it('with no params and multiple children', () =>
+ expect(
+ new GenericAtRule({
+ name: 'at-root',
+ nodes: [{selector: '.foo'}, {selector: '.bar'}],
+ raws: {atRootShorthand: true},
+ }).toString()
+ ).toBe('@at-root {\n .foo {}\n .bar {}\n}'));
+
+ it('with no params and a non-style-rule child', () =>
+ expect(
+ new GenericAtRule({
+ name: 'at-root',
+ nodes: [{name: 'foo'}],
+ raws: {atRootShorthand: true},
+ }).toString()
+ ).toBe('@at-root {\n @foo\n}'));
+
+ it('with params and only a style rule child', () =>
+ expect(
+ new GenericAtRule({
+ name: 'at-root',
+ params: '(with: rule)',
+ nodes: [{selector: '.foo'}],
+ raws: {atRootShorthand: true},
+ }).toString()
+ ).toBe('@at-root (with: rule) {\n .foo {}\n}'));
+
+ it("that's not @at-root", () =>
+ expect(
+ new GenericAtRule({
+ name: 'at-wrong',
+ nodes: [{selector: '.foo'}],
+ raws: {atRootShorthand: true},
+ }).toString()
+ ).toBe('@at-wrong {\n .foo {}\n}'));
+ });
+ });
+ });
+});
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..7228613f8
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts
@@ -0,0 +1,77 @@
+// 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, Comment, Declaration, NewNode} from '.';
+
+/**
+ * A fake intermediate class to convince TypeScript to use Sass types for
+ * various upstream methods.
+ *
+ * @hidden
+ */
+export class _AtRule extends postcss.AtRule {
+ // 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;
+ insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this;
+ insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this;
+ next(): ChildNode | undefined;
+ prepend(...nodes: NewNode[]): this;
+ prev(): ChildNode | undefined;
+ replaceWith(...nodes: NewNode[]): 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/comment-internal.d.ts b/pkg/sass-parser/lib/src/statement/comment-internal.d.ts
new file mode 100644
index 000000000..eb49874bf
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/comment-internal.d.ts
@@ -0,0 +1,31 @@
+// 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 {Root} from './root';
+import {ChildNode, NewNode} from '.';
+
+/**
+ * A fake intermediate class to convince TypeScript to use Sass types for
+ * various upstream methods.
+ *
+ * @hidden
+ */
+export class _Comment extends postcss.Comment {
+ // Override the PostCSS 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;
+ assign(overrides: Partial): this;
+ before(newNode: NewNode): this;
+ cloneAfter(overrides?: Partial): this;
+ cloneBefore(overrides?: Partial): this;
+ next(): ChildNode | undefined;
+ prev(): ChildNode | undefined;
+ replaceWith(...nodes: NewNode[]): this;
+ root(): Root;
+}
diff --git a/pkg/sass-parser/lib/src/statement/comment-internal.js b/pkg/sass-parser/lib/src/statement/comment-internal.js
new file mode 100644
index 000000000..3304da6b3
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/comment-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._Comment = require('postcss').Comment;
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/css-comment.test.ts b/pkg/sass-parser/lib/src/statement/css-comment.test.ts
new file mode 100644
index 000000000..ae1e5bc65
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/css-comment.test.ts
@@ -0,0 +1,325 @@
+// 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 {CssComment, Interpolation, Root, css, sass, scss} from '../..';
+import * as utils from '../../../test/utils';
+
+describe('a CSS-style comment', () => {
+ let node: CssComment;
+ function describeNode(description: string, create: () => CssComment): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has type comment', () => expect(node.type).toBe('comment'));
+
+ it('has sassType comment', () => expect(node.sassType).toBe('comment'));
+
+ it('has matching textInterpolation', () =>
+ expect(node).toHaveInterpolation('textInterpolation', 'foo'));
+
+ it('has matching text', () => expect(node.text).toBe('foo'));
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('/* foo */').nodes[0] as CssComment
+ );
+
+ describeNode(
+ 'parsed as CSS',
+ () => css.parse('/* foo */').nodes[0] as CssComment
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('/* foo').nodes[0] as CssComment
+ );
+
+ describe('constructed manually', () => {
+ describeNode(
+ 'with an interpolation',
+ () =>
+ new CssComment({
+ textInterpolation: new Interpolation({nodes: ['foo']}),
+ })
+ );
+
+ describeNode('with a text string', () => new CssComment({text: 'foo'}));
+ });
+
+ describe('constructed from ChildProps', () => {
+ describeNode('with an interpolation', () =>
+ utils.fromChildProps({
+ textInterpolation: new Interpolation({nodes: ['foo']}),
+ })
+ );
+
+ describeNode('with a text string', () =>
+ utils.fromChildProps({text: 'foo'})
+ );
+ });
+
+ describe('parses raws', () => {
+ describe('in SCSS', () => {
+ it('with whitespace before and after text', () =>
+ expect((scss.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({
+ left: ' ',
+ right: ' ',
+ closed: true,
+ }));
+
+ it('with whitespace before and after interpolation', () =>
+ expect(
+ (scss.parse('/* #{foo} */').nodes[0] as CssComment).raws
+ ).toEqual({left: ' ', right: ' ', closed: true}));
+
+ it('without whitespace before and after text', () =>
+ expect((scss.parse('/*foo*/').nodes[0] as CssComment).raws).toEqual({
+ left: '',
+ right: '',
+ closed: true,
+ }));
+
+ it('without whitespace before and after interpolation', () =>
+ expect((scss.parse('/*#{foo}*/').nodes[0] as CssComment).raws).toEqual({
+ left: '',
+ right: '',
+ closed: true,
+ }));
+
+ it('with whitespace and no text', () =>
+ expect((scss.parse('/* */').nodes[0] as CssComment).raws).toEqual({
+ left: ' ',
+ right: '',
+ closed: true,
+ }));
+
+ it('with no whitespace and no text', () =>
+ expect((scss.parse('/**/').nodes[0] as CssComment).raws).toEqual({
+ left: '',
+ right: '',
+ closed: true,
+ }));
+ });
+
+ describe('in Sass', () => {
+ // TODO: Test explicit whitespace after text and interpolation once we
+ // properly parse raws from somewhere other than the original text.
+
+ it('with whitespace before text', () =>
+ expect((sass.parse('/* foo').nodes[0] as CssComment).raws).toEqual({
+ left: ' ',
+ right: '',
+ closed: false,
+ }));
+
+ it('with whitespace before interpolation', () =>
+ expect((sass.parse('/* #{foo}').nodes[0] as CssComment).raws).toEqual({
+ left: ' ',
+ right: '',
+ closed: false,
+ }));
+
+ it('without whitespace before and after text', () =>
+ expect((sass.parse('/*foo').nodes[0] as CssComment).raws).toEqual({
+ left: '',
+ right: '',
+ closed: false,
+ }));
+
+ it('without whitespace before and after interpolation', () =>
+ expect((sass.parse('/*#{foo}').nodes[0] as CssComment).raws).toEqual({
+ left: '',
+ right: '',
+ closed: false,
+ }));
+
+ it('with no whitespace and no text', () =>
+ expect((sass.parse('/*').nodes[0] as CssComment).raws).toEqual({
+ left: '',
+ right: '',
+ closed: false,
+ }));
+
+ it('with a trailing */', () =>
+ expect((sass.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({
+ left: ' ',
+ right: ' ',
+ closed: true,
+ }));
+ });
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ it('with default raws', () =>
+ expect(new CssComment({text: 'foo'}).toString()).toBe('/* foo */'));
+
+ it('with left', () =>
+ expect(
+ new CssComment({
+ text: 'foo',
+ raws: {left: '\n'},
+ }).toString()
+ ).toBe('/*\nfoo */'));
+
+ it('with right', () =>
+ expect(
+ new CssComment({
+ text: 'foo',
+ raws: {right: '\n'},
+ }).toString()
+ ).toBe('/* foo\n*/'));
+
+ it('with before', () =>
+ expect(
+ new Root({
+ nodes: [new CssComment({text: 'foo', raws: {before: '/**/'}})],
+ }).toString()
+ ).toBe('/**//* foo */'));
+ });
+ });
+
+ describe('assigned new text', () => {
+ beforeEach(() => {
+ node = scss.parse('/* foo */').nodes[0] as CssComment;
+ });
+
+ it("removes the old text's parent", () => {
+ const oldText = node.textInterpolation!;
+ node.textInterpolation = 'bar';
+ expect(oldText.parent).toBeUndefined();
+ });
+
+ it("assigns the new interpolation's parent", () => {
+ const interpolation = new Interpolation({nodes: ['bar']});
+ node.textInterpolation = interpolation;
+ expect(interpolation.parent).toBe(node);
+ });
+
+ it('assigns the interpolation explicitly', () => {
+ const interpolation = new Interpolation({nodes: ['bar']});
+ node.textInterpolation = interpolation;
+ expect(node.textInterpolation).toBe(interpolation);
+ });
+
+ it('assigns the interpolation as a string', () => {
+ node.textInterpolation = 'bar';
+ expect(node).toHaveInterpolation('textInterpolation', 'bar');
+ });
+
+ it('assigns the interpolation as text', () => {
+ node.text = 'bar';
+ expect(node).toHaveInterpolation('textInterpolation', 'bar');
+ });
+ });
+
+ describe('clone', () => {
+ let original: CssComment;
+ beforeEach(
+ () => void (original = scss.parse('/* foo */').nodes[0] as CssComment)
+ );
+
+ describe('with no overrides', () => {
+ let clone: CssComment;
+ beforeEach(() => {
+ clone = original.clone();
+ });
+
+ describe('has the same properties:', () => {
+ it('textInterpolation', () =>
+ expect(clone).toHaveInterpolation('textInterpolation', 'foo'));
+
+ it('text', () => expect(clone.text).toBe('foo'));
+
+ it('raws', () =>
+ expect(clone.raws).toEqual({left: ' ', right: ' ', closed: true}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of ['textInterpolation', 'raws'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+ });
+
+ describe('overrides', () => {
+ describe('text', () => {
+ describe('defined', () => {
+ let clone: CssComment;
+ beforeEach(() => {
+ clone = original.clone({text: 'bar'});
+ });
+
+ it('changes text', () => expect(clone.text).toBe('bar'));
+
+ it('changes textInterpolation', () =>
+ expect(clone).toHaveInterpolation('textInterpolation', 'bar'));
+ });
+
+ describe('undefined', () => {
+ let clone: CssComment;
+ beforeEach(() => {
+ clone = original.clone({text: undefined});
+ });
+
+ it('preserves text', () => expect(clone.text).toBe('foo'));
+
+ it('preserves textInterpolation', () =>
+ expect(clone).toHaveInterpolation('textInterpolation', 'foo'));
+ });
+ });
+
+ describe('textInterpolation', () => {
+ describe('defined', () => {
+ let clone: CssComment;
+ beforeEach(() => {
+ clone = original.clone({
+ textInterpolation: new Interpolation({nodes: ['baz']}),
+ });
+ });
+
+ it('changes text', () => expect(clone.text).toBe('baz'));
+
+ it('changes textInterpolation', () =>
+ expect(clone).toHaveInterpolation('textInterpolation', 'baz'));
+ });
+
+ describe('undefined', () => {
+ let clone: CssComment;
+ beforeEach(() => {
+ clone = original.clone({textInterpolation: undefined});
+ });
+
+ it('preserves text', () => expect(clone.text).toBe('foo'));
+
+ it('preserves textInterpolation', () =>
+ expect(clone).toHaveInterpolation('textInterpolation', 'foo'));
+ });
+ });
+
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {right: ' '}}).raws).toEqual({
+ right: ' ',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ left: ' ',
+ right: ' ',
+ closed: true,
+ }));
+ });
+ });
+ });
+
+ it('toJSON', () =>
+ expect(scss.parse('/* foo */').nodes[0]).toMatchSnapshot());
+});
diff --git a/pkg/sass-parser/lib/src/statement/css-comment.ts b/pkg/sass-parser/lib/src/statement/css-comment.ts
new file mode 100644
index 000000000..f2565caa0
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/css-comment.ts
@@ -0,0 +1,164 @@
+// 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 {CommentRaws} from 'postcss/lib/comment';
+
+import {convertExpression} from '../expression/convert';
+import {LazySource} from '../lazy-source';
+import type * as sassInternal from '../sass-internal';
+import {Interpolation} from '../interpolation';
+import * as utils from '../utils';
+import {ContainerProps, Statement, StatementWithChildren} from '.';
+import {_Comment} from './comment-internal';
+import {interceptIsClean} from './intercept-is-clean';
+import * as sassParser from '../..';
+
+/**
+ * The set of raws supported by {@link CssComment}.
+ *
+ * @category Statement
+ */
+export interface CssCommentRaws extends CommentRaws {
+ /**
+ * In the indented syntax, this indicates whether a comment is explicitly
+ * closed with a `*\/`. It's ignored in other syntaxes.
+ *
+ * It defaults to false.
+ */
+ closed?: boolean;
+}
+
+/**
+ * The initializer properties for {@link CssComment}.
+ *
+ * @category Statement
+ */
+export type CssCommentProps = ContainerProps & {
+ raws?: CssCommentRaws;
+} & ({text: string} | {textInterpolation: Interpolation | string});
+
+/**
+ * A CSS-style "loud" comment. Extends [`postcss.Comment`].
+ *
+ * [`postcss.Comment`]: https://postcss.org/api/#comment
+ *
+ * @category Statement
+ */
+export class CssComment
+ extends _Comment>
+ implements Statement
+{
+ readonly sassType = 'comment' as const;
+ declare parent: StatementWithChildren | undefined;
+ declare raws: CssCommentRaws;
+
+ get text(): string {
+ return this.textInterpolation.toString();
+ }
+ set text(value: string) {
+ this.textInterpolation = value;
+ }
+
+ /** The interpolation that represents this selector's contents. */
+ get textInterpolation(): Interpolation {
+ return this._textInterpolation!;
+ }
+ set textInterpolation(textInterpolation: Interpolation | string) {
+ // TODO - postcss/postcss#1957: Mark this as dirty
+ if (this._textInterpolation) {
+ this._textInterpolation.parent = undefined;
+ }
+ if (typeof textInterpolation === 'string') {
+ textInterpolation = new Interpolation({
+ nodes: [textInterpolation],
+ });
+ }
+ textInterpolation.parent = this;
+ this._textInterpolation = textInterpolation;
+ }
+ private _textInterpolation?: Interpolation;
+
+ constructor(defaults: CssCommentProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.LoudComment);
+ constructor(defaults?: CssCommentProps, inner?: sassInternal.LoudComment) {
+ super(defaults as unknown as postcss.CommentProps);
+
+ if (inner) {
+ this.source = new LazySource(inner);
+ const nodes = [...inner.text.contents];
+
+ // The interpolation's contents are guaranteed to begin with a string,
+ // because Sass includes the `/*`.
+ let first = nodes[0] as string;
+ const firstMatch = first.match(/^\/\*([ \t\n\r\f]*)/)!;
+ this.raws.left ??= firstMatch[1];
+ first = first.substring(firstMatch[0].length);
+ if (first.length === 0) {
+ nodes.shift();
+ } else {
+ nodes[0] = first;
+ }
+
+ // The interpolation will end with `*/` in SCSS, but not necessarily in
+ // the indented syntax.
+ let last = nodes.at(-1);
+ if (typeof last === 'string') {
+ const lastMatch = last.match(/([ \t\n\r\f]*)\*\/$/);
+ this.raws.right ??= lastMatch?.[1] ?? '';
+ this.raws.closed = !!lastMatch;
+ if (lastMatch) {
+ last = last.substring(0, last.length - lastMatch[0].length);
+ if (last.length === 0) {
+ nodes.pop();
+ } else {
+ nodes[0] = last;
+ }
+ }
+ } else {
+ this.raws.right ??= '';
+ this.raws.closed = false;
+ }
+
+ this.textInterpolation = new Interpolation();
+ for (const child of nodes) {
+ this.textInterpolation.append(
+ typeof child === 'string' ? child : convertExpression(child)
+ );
+ }
+ }
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(
+ this,
+ overrides,
+ ['raws', 'textInterpolation'],
+ ['text']
+ );
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(this, ['text', 'textInterpolation'], inputs);
+ }
+
+ /** @hidden */
+ toString(
+ stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
+ .stringify
+ ): string {
+ return super.toString(stringifier);
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [this.textInterpolation];
+ }
+}
+
+interceptIsClean(CssComment);
diff --git a/pkg/sass-parser/lib/src/statement/debug-rule.test.ts b/pkg/sass-parser/lib/src/statement/debug-rule.test.ts
new file mode 100644
index 000000000..2ac421dbc
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/debug-rule.test.ts
@@ -0,0 +1,205 @@
+// 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 {DebugRule, StringExpression, sass, scss} from '../..';
+import * as utils from '../../../test/utils';
+
+describe('a @debug rule', () => {
+ let node: DebugRule;
+ function describeNode(description: string, create: () => DebugRule): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a name', () => expect(node.name.toString()).toBe('debug'));
+
+ it('has an expression', () =>
+ expect(node).toHaveStringExpression('debugExpression', 'foo'));
+
+ it('has matching params', () => expect(node.params).toBe('foo'));
+
+ it('has undefined nodes', () => expect(node.nodes).toBeUndefined());
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('@debug foo').nodes[0] as DebugRule
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('@debug foo').nodes[0] as DebugRule
+ );
+
+ describeNode(
+ 'constructed manually',
+ () =>
+ new DebugRule({
+ debugExpression: {text: 'foo'},
+ })
+ );
+
+ describeNode('constructed from ChildProps', () =>
+ utils.fromChildProps({
+ debugExpression: {text: 'foo'},
+ })
+ );
+
+ it('throws an error when assigned a new name', () =>
+ expect(
+ () =>
+ (new DebugRule({
+ debugExpression: {text: 'foo'},
+ }).name = 'bar')
+ ).toThrow());
+
+ describe('assigned a new expression', () => {
+ beforeEach(() => {
+ node = scss.parse('@debug foo').nodes[0] as DebugRule;
+ });
+
+ it('sets an empty string expression as undefined params', () => {
+ node.params = undefined;
+ expect(node.params).toBe('');
+ expect(node).toHaveStringExpression('debugExpression', '');
+ });
+
+ it('sets an empty string expression as empty string params', () => {
+ node.params = '';
+ expect(node.params).toBe('');
+ expect(node).toHaveStringExpression('debugExpression', '');
+ });
+
+ it("removes the old expression's parent", () => {
+ const oldExpression = node.debugExpression;
+ node.debugExpression = {text: 'bar'};
+ expect(oldExpression.parent).toBeUndefined();
+ });
+
+ it("assigns the new expression's parent", () => {
+ const expression = new StringExpression({text: 'bar'});
+ node.debugExpression = expression;
+ expect(expression.parent).toBe(node);
+ });
+
+ it('assigns the expression explicitly', () => {
+ const expression = new StringExpression({text: 'bar'});
+ node.debugExpression = expression;
+ expect(node.debugExpression).toBe(expression);
+ });
+
+ it('assigns the expression as ExpressionProps', () => {
+ node.debugExpression = {text: 'bar'};
+ expect(node).toHaveStringExpression('debugExpression', 'bar');
+ });
+
+ it('assigns the expression as params', () => {
+ node.params = 'bar';
+ expect(node).toHaveStringExpression('debugExpression', 'bar');
+ });
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ it('with default raws', () =>
+ expect(
+ new DebugRule({
+ debugExpression: {text: 'foo'},
+ }).toString()
+ ).toBe('@debug foo;'));
+
+ it('with afterName', () =>
+ expect(
+ new DebugRule({
+ debugExpression: {text: 'foo'},
+ raws: {afterName: '/**/'},
+ }).toString()
+ ).toBe('@debug/**/foo;'));
+
+ it('with between', () =>
+ expect(
+ new DebugRule({
+ debugExpression: {text: 'foo'},
+ raws: {between: '/**/'},
+ }).toString()
+ ).toBe('@debug foo/**/;'));
+ });
+ });
+
+ describe('clone', () => {
+ let original: DebugRule;
+ beforeEach(() => {
+ original = scss.parse('@debug foo').nodes[0] as DebugRule;
+ // TODO: remove this once raws are properly parsed
+ original.raws.between = ' ';
+ });
+
+ describe('with no overrides', () => {
+ let clone: DebugRule;
+ beforeEach(() => void (clone = original.clone()));
+
+ describe('has the same properties:', () => {
+ it('params', () => expect(clone.params).toBe('foo'));
+
+ it('debugExpression', () =>
+ expect(clone).toHaveStringExpression('debugExpression', 'foo'));
+
+ it('raws', () => expect(clone.raws).toEqual({between: ' '}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of ['debugExpression', 'raws'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+ });
+
+ describe('overrides', () => {
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({
+ afterName: ' ',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ between: ' ',
+ }));
+ });
+
+ describe('debugExpression', () => {
+ describe('defined', () => {
+ let clone: DebugRule;
+ beforeEach(() => {
+ clone = original.clone({debugExpression: {text: 'bar'}});
+ });
+
+ it('changes params', () => expect(clone.params).toBe('bar'));
+
+ it('changes debugExpression', () =>
+ expect(clone).toHaveStringExpression('debugExpression', 'bar'));
+ });
+
+ describe('undefined', () => {
+ let clone: DebugRule;
+ beforeEach(() => {
+ clone = original.clone({debugExpression: undefined});
+ });
+
+ it('preserves params', () => expect(clone.params).toBe('foo'));
+
+ it('preserves debugExpression', () =>
+ expect(clone).toHaveStringExpression('debugExpression', 'foo'));
+ });
+ });
+ });
+ });
+
+ it('toJSON', () =>
+ expect(scss.parse('@debug foo').nodes[0]).toMatchSnapshot());
+});
diff --git a/pkg/sass-parser/lib/src/statement/debug-rule.ts b/pkg/sass-parser/lib/src/statement/debug-rule.ts
new file mode 100644
index 000000000..d0030d81d
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/debug-rule.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 postcss from 'postcss';
+import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule';
+
+import {convertExpression} from '../expression/convert';
+import {Expression, ExpressionProps} from '../expression';
+import {fromProps} from '../expression/from-props';
+import {LazySource} from '../lazy-source';
+import type * as sassInternal from '../sass-internal';
+import * as utils from '../utils';
+import {Statement, StatementWithChildren} from '.';
+import {_AtRule} from './at-rule-internal';
+import {interceptIsClean} from './intercept-is-clean';
+import * as sassParser from '../..';
+
+/**
+ * The set of raws supported by {@link DebugRule}.
+ *
+ * @category Statement
+ */
+export type DebugRuleRaws = Pick<
+ PostcssAtRuleRaws,
+ 'afterName' | 'before' | 'between'
+>;
+
+/**
+ * The initializer properties for {@link DebugRule}.
+ *
+ * @category Statement
+ */
+export type DebugRuleProps = postcss.NodeProps & {
+ raws?: DebugRuleRaws;
+ debugExpression: Expression | ExpressionProps;
+};
+
+/**
+ * A `@debug` rule. Extends [`postcss.AtRule`].
+ *
+ * [`postcss.AtRule`]: https://postcss.org/api/#atrule
+ *
+ * @category Statement
+ */
+export class DebugRule
+ extends _AtRule>
+ implements Statement
+{
+ readonly sassType = 'debug-rule' as const;
+ declare parent: StatementWithChildren | undefined;
+ declare raws: DebugRuleRaws;
+ declare readonly nodes: undefined;
+
+ get name(): string {
+ return 'debug';
+ }
+ set name(value: string) {
+ throw new Error("DebugRule.name can't be overwritten.");
+ }
+
+ get params(): string {
+ return this.debugExpression.toString();
+ }
+ set params(value: string | number | undefined) {
+ this.debugExpression = {text: value?.toString() ?? ''};
+ }
+
+ /** The expresison whose value is emitted when the debug rule is executed. */
+ get debugExpression(): Expression {
+ return this._debugExpression!;
+ }
+ set debugExpression(debugExpression: Expression | ExpressionProps) {
+ if (this._debugExpression) this._debugExpression.parent = undefined;
+ if (!('sassType' in debugExpression)) {
+ debugExpression = fromProps(debugExpression);
+ }
+ if (debugExpression) debugExpression.parent = this;
+ this._debugExpression = debugExpression;
+ }
+ private _debugExpression?: Expression;
+
+ constructor(defaults: DebugRuleProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.DebugRule);
+ constructor(defaults?: DebugRuleProps, inner?: sassInternal.DebugRule) {
+ super(defaults as unknown as postcss.AtRuleProps);
+
+ if (inner) {
+ this.source = new LazySource(inner);
+ this.debugExpression = convertExpression(inner.expression);
+ }
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(
+ this,
+ overrides,
+ ['raws', 'debugExpression'],
+ [{name: 'params', explicitUndefined: true}]
+ );
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(
+ this,
+ ['name', 'debugExpression', 'params', 'nodes'],
+ inputs
+ );
+ }
+
+ /** @hidden */
+ toString(
+ stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
+ .stringify
+ ): string {
+ return super.toString(stringifier);
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [this.debugExpression];
+ }
+}
+
+interceptIsClean(DebugRule);
diff --git a/pkg/sass-parser/lib/src/statement/each-rule.test.ts b/pkg/sass-parser/lib/src/statement/each-rule.test.ts
new file mode 100644
index 000000000..d70d53992
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/each-rule.test.ts
@@ -0,0 +1,302 @@
+// 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 {EachRule, GenericAtRule, StringExpression, sass, scss} from '../..';
+import * as utils from '../../../test/utils';
+
+describe('an @each rule', () => {
+ let node: EachRule;
+ describe('with empty children', () => {
+ function describeNode(description: string, create: () => EachRule): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a name', () => expect(node.name.toString()).toBe('each'));
+
+ it('has variables', () =>
+ expect(node.variables).toEqual(['foo', 'bar']));
+
+ it('has an expression', () =>
+ expect(node).toHaveStringExpression('eachExpression', 'baz'));
+
+ it('has matching params', () =>
+ expect(node.params).toBe('$foo, $bar in baz'));
+
+ it('has empty nodes', () => expect(node.nodes).toEqual([]));
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('@each $foo, $bar in baz').nodes[0] as EachRule
+ );
+
+ describeNode(
+ 'constructed manually',
+ () =>
+ new EachRule({
+ variables: ['foo', 'bar'],
+ eachExpression: {text: 'baz'},
+ })
+ );
+
+ describeNode('constructed from ChildProps', () =>
+ utils.fromChildProps({
+ variables: ['foo', 'bar'],
+ eachExpression: {text: 'baz'},
+ })
+ );
+ });
+
+ describe('with a child', () => {
+ function describeNode(description: string, create: () => EachRule): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a name', () => expect(node.name.toString()).toBe('each'));
+
+ it('has variables', () =>
+ expect(node.variables).toEqual(['foo', 'bar']));
+
+ it('has an expression', () =>
+ expect(node).toHaveStringExpression('eachExpression', 'baz'));
+
+ it('has matching params', () =>
+ expect(node.params).toBe('$foo, $bar in baz'));
+
+ it('has a child node', () => {
+ expect(node.nodes).toHaveLength(1);
+ expect(node.nodes[0]).toBeInstanceOf(GenericAtRule);
+ expect(node.nodes[0]).toHaveProperty('name', 'child');
+ });
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('@each $foo, $bar in baz {@child}').nodes[0] as EachRule
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('@each $foo, $bar in baz\n @child').nodes[0] as EachRule
+ );
+
+ describeNode(
+ 'constructed manually',
+ () =>
+ new EachRule({
+ variables: ['foo', 'bar'],
+ eachExpression: {text: 'baz'},
+ nodes: [{name: 'child'}],
+ })
+ );
+
+ describeNode('constructed from ChildProps', () =>
+ utils.fromChildProps({
+ variables: ['foo', 'bar'],
+ eachExpression: {text: 'baz'},
+ nodes: [{name: 'child'}],
+ })
+ );
+ });
+
+ describe('throws an error when assigned a new', () => {
+ beforeEach(
+ () =>
+ void (node = new EachRule({
+ variables: ['foo', 'bar'],
+ eachExpression: {text: 'baz'},
+ }))
+ );
+
+ it('name', () => expect(() => (node.name = 'qux')).toThrow());
+
+ it('params', () =>
+ expect(() => (node.params = '$zip, $zap in qux')).toThrow());
+ });
+
+ describe('assigned a new expression', () => {
+ beforeEach(() => {
+ node = scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule;
+ });
+
+ it("removes the old expression's parent", () => {
+ const oldExpression = node.eachExpression;
+ node.eachExpression = {text: 'qux'};
+ expect(oldExpression.parent).toBeUndefined();
+ });
+
+ it("assigns the new expression's parent", () => {
+ const expression = new StringExpression({text: 'qux'});
+ node.eachExpression = expression;
+ expect(expression.parent).toBe(node);
+ });
+
+ it('assigns the expression explicitly', () => {
+ const expression = new StringExpression({text: 'qux'});
+ node.eachExpression = expression;
+ expect(node.eachExpression).toBe(expression);
+ });
+
+ it('assigns the expression as ExpressionProps', () => {
+ node.eachExpression = {text: 'qux'};
+ expect(node).toHaveStringExpression('eachExpression', 'qux');
+ });
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ it('with default raws', () =>
+ expect(
+ new EachRule({
+ variables: ['foo', 'bar'],
+ eachExpression: {text: 'baz'},
+ }).toString()
+ ).toBe('@each $foo, $bar in baz {}'));
+
+ it('with afterName', () =>
+ expect(
+ new EachRule({
+ variables: ['foo', 'bar'],
+ eachExpression: {text: 'baz'},
+ raws: {afterName: '/**/'},
+ }).toString()
+ ).toBe('@each/**/$foo, $bar in baz {}'));
+
+ it('with afterVariables', () =>
+ expect(
+ new EachRule({
+ variables: ['foo', 'bar'],
+ eachExpression: {text: 'baz'},
+ raws: {afterVariables: ['/**/,', '/* */']},
+ }).toString()
+ ).toBe('@each $foo/**/,$bar/* */in baz {}'));
+
+ it('with afterIn', () =>
+ expect(
+ new EachRule({
+ variables: ['foo', 'bar'],
+ eachExpression: {text: 'baz'},
+ raws: {afterIn: '/**/'},
+ }).toString()
+ ).toBe('@each $foo, $bar in/**/baz {}'));
+ });
+ });
+
+ describe('clone', () => {
+ let original: EachRule;
+ beforeEach(() => {
+ original = scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule;
+ // TODO: remove this once raws are properly parsed
+ original.raws.between = ' ';
+ });
+
+ describe('with no overrides', () => {
+ let clone: EachRule;
+ beforeEach(() => void (clone = original.clone()));
+
+ describe('has the same properties:', () => {
+ it('params', () => expect(clone.params).toBe('$foo, $bar in baz'));
+
+ it('variables', () => expect(clone.variables).toEqual(['foo', 'bar']));
+
+ it('eachExpression', () =>
+ expect(clone).toHaveStringExpression('eachExpression', 'baz'));
+
+ it('raws', () => expect(clone.raws).toEqual({between: ' '}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of ['variables', 'eachExpression', 'raws'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+ });
+
+ describe('overrides', () => {
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({
+ afterName: ' ',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ between: ' ',
+ }));
+ });
+
+ describe('variables', () => {
+ describe('defined', () => {
+ let clone: EachRule;
+ beforeEach(() => {
+ clone = original.clone({variables: ['zip', 'zap']});
+ });
+
+ it('changes params', () =>
+ expect(clone.params).toBe('$zip, $zap in baz'));
+
+ it('changes variables', () =>
+ expect(clone.variables).toEqual(['zip', 'zap']));
+ });
+
+ describe('undefined', () => {
+ let clone: EachRule;
+ beforeEach(() => {
+ clone = original.clone({variables: undefined});
+ });
+
+ it('preserves params', () =>
+ expect(clone.params).toBe('$foo, $bar in baz'));
+
+ it('preserves variables', () =>
+ expect(clone.variables).toEqual(['foo', 'bar']));
+ });
+ });
+
+ describe('eachExpression', () => {
+ describe('defined', () => {
+ let clone: EachRule;
+ beforeEach(() => {
+ clone = original.clone({eachExpression: {text: 'qux'}});
+ });
+
+ it('changes params', () =>
+ expect(clone.params).toBe('$foo, $bar in qux'));
+
+ it('changes eachExpression', () =>
+ expect(clone).toHaveStringExpression('eachExpression', 'qux'));
+ });
+
+ describe('undefined', () => {
+ let clone: EachRule;
+ beforeEach(() => {
+ clone = original.clone({eachExpression: undefined});
+ });
+
+ it('preserves params', () =>
+ expect(clone.params).toBe('$foo, $bar in baz'));
+
+ it('preserves eachExpression', () =>
+ expect(clone).toHaveStringExpression('eachExpression', 'baz'));
+ });
+ });
+ });
+ });
+
+ it('toJSON', () =>
+ expect(
+ scss.parse('@each $foo, $bar in baz {}').nodes[0]
+ ).toMatchSnapshot());
+});
diff --git a/pkg/sass-parser/lib/src/statement/each-rule.ts b/pkg/sass-parser/lib/src/statement/each-rule.ts
new file mode 100644
index 000000000..e0339f961
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/each-rule.ts
@@ -0,0 +1,165 @@
+// Copyright 2024 Google Inc. Use of this source code is governed by an
+// MIT-style license that can be found in the LICENSE file or at
+// https://opensource.org/licenses/MIT.
+
+import * as postcss from 'postcss';
+import type {AtRuleRaws} from 'postcss/lib/at-rule';
+
+import {convertExpression} from '../expression/convert';
+import {Expression, ExpressionProps} from '../expression';
+import {fromProps} from '../expression/from-props';
+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 {_AtRule} from './at-rule-internal';
+import {interceptIsClean} from './intercept-is-clean';
+import * as sassParser from '../..';
+
+/**
+ * The set of raws supported by {@link EachRule}.
+ *
+ * @category Statement
+ */
+export interface EachRuleRaws extends Omit {
+ /**
+ * The whitespace and commas after each variable in
+ * {@link EachRule.variables}.
+ *
+ * The element at index `i` is included after the variable at index `i`. Any
+ * elements beyond `variables.length` are ignored.
+ */
+ afterVariables?: string[];
+
+ /** The whitespace between `in` and {@link EachRule.eachExpression}. */
+ afterIn?: string;
+}
+
+/**
+ * The initializer properties for {@link EachRule}.
+ *
+ * @category Statement
+ */
+export type EachRuleProps = ContainerProps & {
+ raws?: EachRuleRaws;
+ variables: string[];
+ eachExpression: Expression | ExpressionProps;
+};
+
+/**
+ * An `@each` rule. Extends [`postcss.AtRule`].
+ *
+ * [`postcss.AtRule`]: https://postcss.org/api/#atrule
+ *
+ * @category Statement
+ */
+export class EachRule
+ extends _AtRule>
+ implements Statement
+{
+ readonly sassType = 'each-rule' as const;
+ declare parent: StatementWithChildren | undefined;
+ declare raws: EachRuleRaws;
+ declare nodes: ChildNode[];
+
+ /** The variable names assigned for each iteration, without `"$"`. */
+ declare variables: string[];
+
+ get name(): string {
+ return 'each';
+ }
+ set name(value: string) {
+ throw new Error("EachRule.name can't be overwritten.");
+ }
+
+ get params(): string {
+ let result = '';
+ for (let i = 0; i < this.variables.length; i++) {
+ result +=
+ '$' +
+ this.variables[i] +
+ (this.raws?.afterVariables?.[i] ??
+ (i === this.variables.length - 1 ? ' ' : ', '));
+ }
+ return `${result}in${this.raws.afterIn ?? ' '}${this.eachExpression}`;
+ }
+ set params(value: string | number | undefined) {
+ throw new Error("EachRule.params can't be overwritten.");
+ }
+
+ /** The expresison whose value is iterated over. */
+ get eachExpression(): Expression {
+ return this._eachExpression!;
+ }
+ set eachExpression(eachExpression: Expression | ExpressionProps) {
+ if (this._eachExpression) this._eachExpression.parent = undefined;
+ if (!('sassType' in eachExpression)) {
+ eachExpression = fromProps(eachExpression);
+ }
+ if (eachExpression) eachExpression.parent = this;
+ this._eachExpression = eachExpression;
+ }
+ private _eachExpression?: Expression;
+
+ constructor(defaults: EachRuleProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.EachRule);
+ constructor(defaults?: EachRuleProps, inner?: sassInternal.EachRule) {
+ super(defaults as unknown as postcss.AtRuleProps);
+ this.nodes ??= [];
+
+ if (inner) {
+ this.source = new LazySource(inner);
+ this.variables = [...inner.variables];
+ this.eachExpression = convertExpression(inner.list);
+ appendInternalChildren(this, inner.children);
+ }
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(this, overrides, [
+ 'raws',
+ 'variables',
+ 'eachExpression',
+ ]);
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(
+ this,
+ ['name', 'variables', 'eachExpression', 'params', 'nodes'],
+ inputs
+ );
+ }
+
+ /** @hidden */
+ toString(
+ stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
+ .stringify
+ ): string {
+ return super.toString(stringifier);
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [this.eachExpression];
+ }
+
+ /** @hidden */
+ normalize(node: NewNode, sample?: postcss.Node): ChildNode[] {
+ return normalize(this, node, sample);
+ }
+}
+
+interceptIsClean(EachRule);
diff --git a/pkg/sass-parser/lib/src/statement/error-rule.test.ts b/pkg/sass-parser/lib/src/statement/error-rule.test.ts
new file mode 100644
index 000000000..0524338bf
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/error-rule.test.ts
@@ -0,0 +1,205 @@
+// 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 {ErrorRule, StringExpression, sass, scss} from '../..';
+import * as utils from '../../../test/utils';
+
+describe('a @error rule', () => {
+ let node: ErrorRule;
+ function describeNode(description: string, create: () => ErrorRule): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a name', () => expect(node.name.toString()).toBe('error'));
+
+ it('has an expression', () =>
+ expect(node).toHaveStringExpression('errorExpression', 'foo'));
+
+ it('has matching params', () => expect(node.params).toBe('foo'));
+
+ it('has undefined nodes', () => expect(node.nodes).toBeUndefined());
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('@error foo').nodes[0] as ErrorRule
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('@error foo').nodes[0] as ErrorRule
+ );
+
+ describeNode(
+ 'constructed manually',
+ () =>
+ new ErrorRule({
+ errorExpression: {text: 'foo'},
+ })
+ );
+
+ describeNode('constructed from ChildProps', () =>
+ utils.fromChildProps({
+ errorExpression: {text: 'foo'},
+ })
+ );
+
+ it('throws an error when assigned a new name', () =>
+ expect(
+ () =>
+ (new ErrorRule({
+ errorExpression: {text: 'foo'},
+ }).name = 'bar')
+ ).toThrow());
+
+ describe('assigned a new expression', () => {
+ beforeEach(() => {
+ node = scss.parse('@error foo').nodes[0] as ErrorRule;
+ });
+
+ it('sets an empty string expression as undefined params', () => {
+ node.params = undefined;
+ expect(node.params).toBe('');
+ expect(node).toHaveStringExpression('errorExpression', '');
+ });
+
+ it('sets an empty string expression as empty string params', () => {
+ node.params = '';
+ expect(node.params).toBe('');
+ expect(node).toHaveStringExpression('errorExpression', '');
+ });
+
+ it("removes the old expression's parent", () => {
+ const oldExpression = node.errorExpression;
+ node.errorExpression = {text: 'bar'};
+ expect(oldExpression.parent).toBeUndefined();
+ });
+
+ it("assigns the new expression's parent", () => {
+ const expression = new StringExpression({text: 'bar'});
+ node.errorExpression = expression;
+ expect(expression.parent).toBe(node);
+ });
+
+ it('assigns the expression explicitly', () => {
+ const expression = new StringExpression({text: 'bar'});
+ node.errorExpression = expression;
+ expect(node.errorExpression).toBe(expression);
+ });
+
+ it('assigns the expression as ExpressionProps', () => {
+ node.errorExpression = {text: 'bar'};
+ expect(node).toHaveStringExpression('errorExpression', 'bar');
+ });
+
+ it('assigns the expression as params', () => {
+ node.params = 'bar';
+ expect(node).toHaveStringExpression('errorExpression', 'bar');
+ });
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ it('with default raws', () =>
+ expect(
+ new ErrorRule({
+ errorExpression: {text: 'foo'},
+ }).toString()
+ ).toBe('@error foo;'));
+
+ it('with afterName', () =>
+ expect(
+ new ErrorRule({
+ errorExpression: {text: 'foo'},
+ raws: {afterName: '/**/'},
+ }).toString()
+ ).toBe('@error/**/foo;'));
+
+ it('with between', () =>
+ expect(
+ new ErrorRule({
+ errorExpression: {text: 'foo'},
+ raws: {between: '/**/'},
+ }).toString()
+ ).toBe('@error foo/**/;'));
+ });
+ });
+
+ describe('clone', () => {
+ let original: ErrorRule;
+ beforeEach(() => {
+ original = scss.parse('@error foo').nodes[0] as ErrorRule;
+ // TODO: remove this once raws are properly parsed
+ original.raws.between = ' ';
+ });
+
+ describe('with no overrides', () => {
+ let clone: ErrorRule;
+ beforeEach(() => void (clone = original.clone()));
+
+ describe('has the same properties:', () => {
+ it('params', () => expect(clone.params).toBe('foo'));
+
+ it('errorExpression', () =>
+ expect(clone).toHaveStringExpression('errorExpression', 'foo'));
+
+ it('raws', () => expect(clone.raws).toEqual({between: ' '}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of ['errorExpression', 'raws'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+ });
+
+ describe('overrides', () => {
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({
+ afterName: ' ',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ between: ' ',
+ }));
+ });
+
+ describe('errorExpression', () => {
+ describe('defined', () => {
+ let clone: ErrorRule;
+ beforeEach(() => {
+ clone = original.clone({errorExpression: {text: 'bar'}});
+ });
+
+ it('changes params', () => expect(clone.params).toBe('bar'));
+
+ it('changes errorExpression', () =>
+ expect(clone).toHaveStringExpression('errorExpression', 'bar'));
+ });
+
+ describe('undefined', () => {
+ let clone: ErrorRule;
+ beforeEach(() => {
+ clone = original.clone({errorExpression: undefined});
+ });
+
+ it('preserves params', () => expect(clone.params).toBe('foo'));
+
+ it('preserves errorExpression', () =>
+ expect(clone).toHaveStringExpression('errorExpression', 'foo'));
+ });
+ });
+ });
+ });
+
+ it('toJSON', () =>
+ expect(scss.parse('@error foo').nodes[0]).toMatchSnapshot());
+});
diff --git a/pkg/sass-parser/lib/src/statement/error-rule.ts b/pkg/sass-parser/lib/src/statement/error-rule.ts
new file mode 100644
index 000000000..7b55f4253
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/error-rule.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 postcss from 'postcss';
+import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule';
+
+import {convertExpression} from '../expression/convert';
+import {Expression, ExpressionProps} from '../expression';
+import {fromProps} from '../expression/from-props';
+import {LazySource} from '../lazy-source';
+import type * as sassInternal from '../sass-internal';
+import * as utils from '../utils';
+import {Statement, StatementWithChildren} from '.';
+import {_AtRule} from './at-rule-internal';
+import {interceptIsClean} from './intercept-is-clean';
+import * as sassParser from '../..';
+
+/**
+ * The set of raws supported by {@link ErrorRule}.
+ *
+ * @category Statement
+ */
+export type ErrorRuleRaws = Pick<
+ PostcssAtRuleRaws,
+ 'afterName' | 'before' | 'between'
+>;
+
+/**
+ * The initializer properties for {@link ErrorRule}.
+ *
+ * @category Statement
+ */
+export type ErrorRuleProps = postcss.NodeProps & {
+ raws?: ErrorRuleRaws;
+ errorExpression: Expression | ExpressionProps;
+};
+
+/**
+ * An `@error` rule. Extends [`postcss.AtRule`].
+ *
+ * [`postcss.AtRule`]: https://postcss.org/api/#atrule
+ *
+ * @category Statement
+ */
+export class ErrorRule
+ extends _AtRule>
+ implements Statement
+{
+ readonly sassType = 'error-rule' as const;
+ declare parent: StatementWithChildren | undefined;
+ declare raws: ErrorRuleRaws;
+ declare readonly nodes: undefined;
+
+ get name(): string {
+ return 'error';
+ }
+ set name(value: string) {
+ throw new Error("ErrorRule.name can't be overwritten.");
+ }
+
+ get params(): string {
+ return this.errorExpression.toString();
+ }
+ set params(value: string | number | undefined) {
+ this.errorExpression = {text: value?.toString() ?? ''};
+ }
+
+ /** The expresison whose value is thrown when the error rule is executed. */
+ get errorExpression(): Expression {
+ return this._errorExpression!;
+ }
+ set errorExpression(errorExpression: Expression | ExpressionProps) {
+ if (this._errorExpression) this._errorExpression.parent = undefined;
+ if (!('sassType' in errorExpression)) {
+ errorExpression = fromProps(errorExpression);
+ }
+ if (errorExpression) errorExpression.parent = this;
+ this._errorExpression = errorExpression;
+ }
+ private _errorExpression?: Expression;
+
+ constructor(defaults: ErrorRuleProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.ErrorRule);
+ constructor(defaults?: ErrorRuleProps, inner?: sassInternal.ErrorRule) {
+ super(defaults as unknown as postcss.AtRuleProps);
+
+ if (inner) {
+ this.source = new LazySource(inner);
+ this.errorExpression = convertExpression(inner.expression);
+ }
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(
+ this,
+ overrides,
+ ['raws', 'errorExpression'],
+ [{name: 'params', explicitUndefined: true}]
+ );
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(
+ this,
+ ['name', 'errorExpression', 'params', 'nodes'],
+ inputs
+ );
+ }
+
+ /** @hidden */
+ toString(
+ stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
+ .stringify
+ ): string {
+ return super.toString(stringifier);
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [this.errorExpression];
+ }
+}
+
+interceptIsClean(ErrorRule);
diff --git a/pkg/sass-parser/lib/src/statement/extend-rule.test.ts b/pkg/sass-parser/lib/src/statement/extend-rule.test.ts
new file mode 100644
index 000000000..15485b604
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/extend-rule.test.ts
@@ -0,0 +1,61 @@
+// 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, Rule, scss} from '../..';
+
+describe('an @extend rule', () => {
+ let node: GenericAtRule;
+
+ describe('with no interpolation', () => {
+ beforeEach(
+ () =>
+ void (node = (scss.parse('.foo {@extend .bar}').nodes[0] as Rule)
+ .nodes[0] as GenericAtRule)
+ );
+
+ it('has a name', () => expect(node.name).toBe('extend'));
+
+ it('has a paramsInterpolation', () =>
+ expect(node).toHaveInterpolation('paramsInterpolation', '.bar'));
+
+ it('has matching params', () => expect(node.params).toBe('.bar'));
+ });
+
+ describe('with interpolation', () => {
+ beforeEach(
+ () =>
+ void (node = (scss.parse('.foo {@extend .#{bar}}').nodes[0] as Rule)
+ .nodes[0] as GenericAtRule)
+ );
+
+ it('has a name', () => expect(node.name).toBe('extend'));
+
+ it('has a paramsInterpolation', () => {
+ const params = node.paramsInterpolation!;
+ expect(params.nodes[0]).toBe('.');
+ expect(params).toHaveStringExpression(1, 'bar');
+ });
+
+ it('has matching params', () => expect(node.params).toBe('.#{bar}'));
+ });
+
+ describe('with !optional', () => {
+ beforeEach(
+ () =>
+ void (node = (
+ scss.parse('.foo {@extend .bar !optional}').nodes[0] as Rule
+ ).nodes[0] as GenericAtRule)
+ );
+
+ it('has a name', () => expect(node.name).toBe('extend'));
+
+ it('has a paramsInterpolation', () =>
+ expect(node).toHaveInterpolation(
+ 'paramsInterpolation',
+ '.bar !optional'
+ ));
+
+ it('has matching params', () => expect(node.params).toBe('.bar !optional'));
+ });
+});
diff --git a/pkg/sass-parser/lib/src/statement/for-rule.test.ts b/pkg/sass-parser/lib/src/statement/for-rule.test.ts
new file mode 100644
index 000000000..607dd4217
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/for-rule.test.ts
@@ -0,0 +1,437 @@
+// 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 {ForRule, GenericAtRule, StringExpression, sass, scss} from '../..';
+import * as utils from '../../../test/utils';
+
+describe('an @for rule', () => {
+ let node: ForRule;
+ describe('with empty children', () => {
+ function describeNode(description: string, create: () => ForRule): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a name', () => expect(node.name.toString()).toBe('for'));
+
+ it('has a variable', () => expect(node.variable).toBe('foo'));
+
+ it('has a to', () => expect(node.to).toBe('through'));
+
+ it('has a from expression', () =>
+ expect(node).toHaveStringExpression('fromExpression', 'bar'));
+
+ it('has a to expression', () =>
+ expect(node).toHaveStringExpression('toExpression', 'baz'));
+
+ it('has matching params', () =>
+ expect(node.params).toBe('$foo from bar through baz'));
+
+ it('has empty nodes', () => expect(node.nodes).toEqual([]));
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('@for $foo from bar through baz {}').nodes[0] as ForRule
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('@for $foo from bar through baz').nodes[0] as ForRule
+ );
+
+ describeNode(
+ 'constructed manually',
+ () =>
+ new ForRule({
+ variable: 'foo',
+ to: 'through',
+ fromExpression: {text: 'bar'},
+ toExpression: {text: 'baz'},
+ })
+ );
+
+ describeNode('constructed from ChildProps', () =>
+ utils.fromChildProps({
+ variable: 'foo',
+ to: 'through',
+ fromExpression: {text: 'bar'},
+ toExpression: {text: 'baz'},
+ })
+ );
+ });
+
+ describe('with a child', () => {
+ function describeNode(description: string, create: () => ForRule): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has a name', () => expect(node.name.toString()).toBe('for'));
+
+ it('has a variable', () => expect(node.variable).toBe('foo'));
+
+ it('has a to', () => expect(node.to).toBe('through'));
+
+ it('has a from expression', () =>
+ expect(node).toHaveStringExpression('fromExpression', 'bar'));
+
+ it('has a to expression', () =>
+ expect(node).toHaveStringExpression('toExpression', 'baz'));
+
+ it('has matching params', () =>
+ expect(node.params).toBe('$foo from bar through baz'));
+
+ it('has a child node', () => {
+ expect(node.nodes).toHaveLength(1);
+ expect(node.nodes[0]).toBeInstanceOf(GenericAtRule);
+ expect(node.nodes[0]).toHaveProperty('name', 'child');
+ });
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () =>
+ scss.parse('@for $foo from bar through baz {@child}')
+ .nodes[0] as ForRule
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () =>
+ sass.parse('@for $foo from bar through baz\n @child')
+ .nodes[0] as ForRule
+ );
+
+ describeNode(
+ 'constructed manually',
+ () =>
+ new ForRule({
+ variable: 'foo',
+ to: 'through',
+ fromExpression: {text: 'bar'},
+ toExpression: {text: 'baz'},
+ nodes: [{name: 'child'}],
+ })
+ );
+
+ describeNode('constructed from ChildProps', () =>
+ utils.fromChildProps({
+ variable: 'foo',
+ to: 'through',
+ fromExpression: {text: 'bar'},
+ toExpression: {text: 'baz'},
+ nodes: [{name: 'child'}],
+ })
+ );
+ });
+
+ describe('throws an error when assigned a new', () => {
+ beforeEach(
+ () =>
+ void (node = new ForRule({
+ variable: 'foo',
+ fromExpression: {text: 'bar'},
+ toExpression: {text: 'baz'},
+ }))
+ );
+
+ it('name', () => expect(() => (node.name = 'qux')).toThrow());
+
+ it('params', () =>
+ expect(() => (node.params = '$zip from zap to qux')).toThrow());
+ });
+
+ describe('assigned a new from expression', () => {
+ beforeEach(() => {
+ node = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule;
+ });
+
+ it("removes the old expression's parent", () => {
+ const oldExpression = node.fromExpression;
+ node.fromExpression = {text: 'qux'};
+ expect(oldExpression.parent).toBeUndefined();
+ });
+
+ it("assigns the new expression's parent", () => {
+ const expression = new StringExpression({text: 'qux'});
+ node.fromExpression = expression;
+ expect(expression.parent).toBe(node);
+ });
+
+ it('assigns the expression explicitly', () => {
+ const expression = new StringExpression({text: 'qux'});
+ node.fromExpression = expression;
+ expect(node.fromExpression).toBe(expression);
+ });
+
+ it('assigns the expression as ExpressionProps', () => {
+ node.fromExpression = {text: 'qux'};
+ expect(node).toHaveStringExpression('fromExpression', 'qux');
+ });
+ });
+
+ describe('assigned a new to expression', () => {
+ beforeEach(() => {
+ node = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule;
+ });
+
+ it("removes the old expression's parent", () => {
+ const oldExpression = node.toExpression;
+ node.toExpression = {text: 'qux'};
+ expect(oldExpression.parent).toBeUndefined();
+ });
+
+ it("assigns the new expression's parent", () => {
+ const expression = new StringExpression({text: 'qux'});
+ node.toExpression = expression;
+ expect(expression.parent).toBe(node);
+ });
+
+ it('assigns the expression explicitly', () => {
+ const expression = new StringExpression({text: 'qux'});
+ node.toExpression = expression;
+ expect(node.toExpression).toBe(expression);
+ });
+
+ it('assigns the expression as ExpressionProps', () => {
+ node.toExpression = {text: 'qux'};
+ expect(node).toHaveStringExpression('toExpression', 'qux');
+ });
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ it('with default raws', () =>
+ expect(
+ new ForRule({
+ variable: 'foo',
+ fromExpression: {text: 'bar'},
+ toExpression: {text: 'baz'},
+ }).toString()
+ ).toBe('@for $foo from bar to baz {}'));
+
+ it('with afterName', () =>
+ expect(
+ new ForRule({
+ variable: 'foo',
+ fromExpression: {text: 'bar'},
+ toExpression: {text: 'baz'},
+ raws: {afterName: '/**/'},
+ }).toString()
+ ).toBe('@for/**/$foo from bar to baz {}'));
+
+ it('with afterVariable', () =>
+ expect(
+ new ForRule({
+ variable: 'foo',
+ fromExpression: {text: 'bar'},
+ toExpression: {text: 'baz'},
+ raws: {afterVariable: '/**/'},
+ }).toString()
+ ).toBe('@for $foo/**/from bar to baz {}'));
+
+ it('with afterFrom', () =>
+ expect(
+ new ForRule({
+ variable: 'foo',
+ fromExpression: {text: 'bar'},
+ toExpression: {text: 'baz'},
+ raws: {afterFrom: '/**/'},
+ }).toString()
+ ).toBe('@for $foo from/**/bar to baz {}'));
+
+ it('with afterFromExpression', () =>
+ expect(
+ new ForRule({
+ variable: 'foo',
+ fromExpression: {text: 'bar'},
+ toExpression: {text: 'baz'},
+ raws: {afterFromExpression: '/**/'},
+ }).toString()
+ ).toBe('@for $foo from bar/**/to baz {}'));
+
+ it('with afterTo', () =>
+ expect(
+ new ForRule({
+ variable: 'foo',
+ fromExpression: {text: 'bar'},
+ toExpression: {text: 'baz'},
+ raws: {afterTo: '/**/'},
+ }).toString()
+ ).toBe('@for $foo from bar to/**/baz {}'));
+ });
+ });
+
+ describe('clone', () => {
+ let original: ForRule;
+ beforeEach(() => {
+ original = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule;
+ // TODO: remove this once raws are properly parsed
+ original.raws.between = ' ';
+ });
+
+ describe('with no overrides', () => {
+ let clone: ForRule;
+ beforeEach(() => void (clone = original.clone()));
+
+ describe('has the same properties:', () => {
+ it('params', () => expect(clone.params).toBe('$foo from bar to baz'));
+
+ it('variable', () => expect(clone.variable).toBe('foo'));
+
+ it('to', () => expect(clone.to).toBe('to'));
+
+ it('fromExpression', () =>
+ expect(clone).toHaveStringExpression('fromExpression', 'bar'));
+
+ it('toExpression', () =>
+ expect(clone).toHaveStringExpression('toExpression', 'baz'));
+
+ it('raws', () => expect(clone.raws).toEqual({between: ' '}));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ for (const attr of [
+ 'fromExpression',
+ 'toExpression',
+ 'raws',
+ ] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+ });
+
+ describe('overrides', () => {
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({
+ afterName: ' ',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ between: ' ',
+ }));
+ });
+
+ describe('variable', () => {
+ describe('defined', () => {
+ let clone: ForRule;
+ beforeEach(() => {
+ clone = original.clone({variable: 'zip'});
+ });
+
+ it('changes params', () =>
+ expect(clone.params).toBe('$zip from bar to baz'));
+
+ it('changes variable', () => expect(clone.variable).toBe('zip'));
+ });
+
+ describe('undefined', () => {
+ let clone: ForRule;
+ beforeEach(() => {
+ clone = original.clone({variable: undefined});
+ });
+
+ it('preserves params', () =>
+ expect(clone.params).toBe('$foo from bar to baz'));
+
+ it('preserves variable', () => expect(clone.variable).toBe('foo'));
+ });
+ });
+
+ describe('to', () => {
+ describe('defined', () => {
+ let clone: ForRule;
+ beforeEach(() => {
+ clone = original.clone({to: 'through'});
+ });
+
+ it('changes params', () =>
+ expect(clone.params).toBe('$foo from bar through baz'));
+
+ it('changes tos', () => expect(clone.to).toBe('through'));
+ });
+
+ describe('undefined', () => {
+ let clone: ForRule;
+ beforeEach(() => {
+ clone = original.clone({to: undefined});
+ });
+
+ it('preserves params', () =>
+ expect(clone.params).toBe('$foo from bar to baz'));
+
+ it('preserves tos', () => expect(clone.to).toBe('to'));
+ });
+ });
+
+ describe('fromExpression', () => {
+ describe('defined', () => {
+ let clone: ForRule;
+ beforeEach(() => {
+ clone = original.clone({fromExpression: {text: 'qux'}});
+ });
+
+ it('changes params', () =>
+ expect(clone.params).toBe('$foo from qux to baz'));
+
+ it('changes fromExpression', () =>
+ expect(clone).toHaveStringExpression('fromExpression', 'qux'));
+ });
+
+ describe('undefined', () => {
+ let clone: ForRule;
+ beforeEach(() => {
+ clone = original.clone({fromExpression: undefined});
+ });
+
+ it('preserves params', () =>
+ expect(clone.params).toBe('$foo from bar to baz'));
+
+ it('preserves fromExpression', () =>
+ expect(clone).toHaveStringExpression('fromExpression', 'bar'));
+ });
+ });
+
+ describe('toExpression', () => {
+ describe('defined', () => {
+ let clone: ForRule;
+ beforeEach(() => {
+ clone = original.clone({toExpression: {text: 'qux'}});
+ });
+
+ it('changes params', () =>
+ expect(clone.params).toBe('$foo from bar to qux'));
+
+ it('changes toExpression', () =>
+ expect(clone).toHaveStringExpression('toExpression', 'qux'));
+ });
+
+ describe('undefined', () => {
+ let clone: ForRule;
+ beforeEach(() => {
+ clone = original.clone({toExpression: undefined});
+ });
+
+ it('preserves params', () =>
+ expect(clone.params).toBe('$foo from bar to baz'));
+
+ it('preserves toExpression', () =>
+ expect(clone).toHaveStringExpression('toExpression', 'baz'));
+ });
+ });
+ });
+ });
+
+ it('toJSON', () =>
+ expect(
+ scss.parse('@for $foo from bar to baz {}').nodes[0]
+ ).toMatchSnapshot());
+});
diff --git a/pkg/sass-parser/lib/src/statement/for-rule.ts b/pkg/sass-parser/lib/src/statement/for-rule.ts
new file mode 100644
index 000000000..d67fe91f6
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/for-rule.ts
@@ -0,0 +1,200 @@
+// Copyright 2024 Google Inc. Use of this source code is governed by an
+// MIT-style license that can be found in the LICENSE file or at
+// https://opensource.org/licenses/MIT.
+
+import * as postcss from 'postcss';
+import type {AtRuleRaws} from 'postcss/lib/at-rule';
+
+import {convertExpression} from '../expression/convert';
+import {Expression, ExpressionProps} from '../expression';
+import {fromProps} from '../expression/from-props';
+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 {_AtRule} from './at-rule-internal';
+import {interceptIsClean} from './intercept-is-clean';
+import * as sassParser from '../..';
+
+/**
+ * The set of raws supported by {@link ForRule}.
+ *
+ * @category Statement
+ */
+export interface ForRuleRaws extends Omit {
+ /** The whitespace after {@link ForRule.variable}. */
+ afterVariable?: string;
+
+ /** The whitespace after a {@link ForRule}'s `from` keyword. */
+ afterFrom?: string;
+
+ /** The whitespace after {@link ForRule.fromExpression}. */
+ afterFromExpression?: string;
+
+ /** The whitespace after a {@link ForRule}'s `to` or `through` keyword. */
+ afterTo?: string;
+}
+
+/**
+ * The initializer properties for {@link ForRule}.
+ *
+ * @category Statement
+ */
+export type ForRuleProps = ContainerProps & {
+ raws?: ForRuleRaws;
+ variable: string;
+ fromExpression: Expression | ExpressionProps;
+ toExpression: Expression | ExpressionProps;
+ to?: 'to' | 'through';
+};
+
+/**
+ * A `@for` rule. Extends [`postcss.AtRule`].
+ *
+ * [`postcss.AtRule`]: https://postcss.org/api/#atrule
+ *
+ * @category Statement
+ */
+export class ForRule
+ extends _AtRule>
+ implements Statement
+{
+ readonly sassType = 'for-rule' as const;
+ declare parent: StatementWithChildren | undefined;
+ declare raws: ForRuleRaws;
+ declare nodes: ChildNode[];
+
+ /** The variabl names assigned for for iteration, without `"$"`. */
+ declare variable: string;
+
+ /**
+ * The keyword that appears before {@link toExpression}.
+ *
+ * If this is `"to"`, the loop is exclusive; if it's `"through"`, the loop is
+ * inclusive. It defaults to `"to"` when creating a new `ForRule`.
+ */
+ declare to: 'to' | 'through';
+
+ get name(): string {
+ return 'for';
+ }
+ set name(value: string) {
+ throw new Error("ForRule.name can't be overwritten.");
+ }
+
+ get params(): string {
+ return (
+ `$${this.variable}${this.raws.afterVariable ?? ' '}from` +
+ `${this.raws.afterFrom ?? ' '}${this.fromExpression}` +
+ `${this.raws.afterFromExpression ?? ' '}${this.to}` +
+ `${this.raws.afterTo ?? ' '}${this.toExpression}`
+ );
+ }
+ set params(value: string | number | undefined) {
+ throw new Error("ForRule.params can't be overwritten.");
+ }
+
+ /** The expresison whose value is the starting point of the iteration. */
+ get fromExpression(): Expression {
+ return this._fromExpression!;
+ }
+ set fromExpression(fromExpression: Expression | ExpressionProps) {
+ if (this._fromExpression) this._fromExpression.parent = undefined;
+ if (!('sassType' in fromExpression)) {
+ fromExpression = fromProps(fromExpression);
+ }
+ if (fromExpression) fromExpression.parent = this;
+ this._fromExpression = fromExpression;
+ }
+ private _fromExpression?: Expression;
+
+ /** The expresison whose value is the ending point of the iteration. */
+ get toExpression(): Expression {
+ return this._toExpression!;
+ }
+ set toExpression(toExpression: Expression | ExpressionProps) {
+ if (this._toExpression) this._toExpression.parent = undefined;
+ if (!('sassType' in toExpression)) {
+ toExpression = fromProps(toExpression);
+ }
+ if (toExpression) toExpression.parent = this;
+ this._toExpression = toExpression;
+ }
+ private _toExpression?: Expression;
+
+ constructor(defaults: ForRuleProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.ForRule);
+ constructor(defaults?: ForRuleProps, inner?: sassInternal.ForRule) {
+ super(defaults as unknown as postcss.AtRuleProps);
+ this.nodes ??= [];
+
+ if (inner) {
+ this.source = new LazySource(inner);
+ this.variable = inner.variable;
+ this.to = inner.isExclusive ? 'to' : 'through';
+ this.fromExpression = convertExpression(inner.from);
+ this.toExpression = convertExpression(inner.to);
+ appendInternalChildren(this, inner.children);
+ }
+
+ this.to ??= 'to';
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(this, overrides, [
+ 'raws',
+ 'variable',
+ 'to',
+ 'fromExpression',
+ 'toExpression',
+ ]);
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(
+ this,
+ [
+ 'name',
+ 'variable',
+ 'to',
+ 'fromExpression',
+ 'toExpression',
+ 'params',
+ 'nodes',
+ ],
+ inputs
+ );
+ }
+
+ /** @hidden */
+ toString(
+ stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
+ .stringify
+ ): string {
+ return super.toString(stringifier);
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [this.fromExpression, this.toExpression];
+ }
+
+ /** @hidden */
+ normalize(node: NewNode, sample?: postcss.Node): ChildNode[] {
+ return normalize(this, node, sample);
+ }
+}
+
+interceptIsClean(ForRule);
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..97ae32e05
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts
@@ -0,0 +1,212 @@
+// 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
+ * {@link GenericAtRule.paramInterpolation} has its own raws.
+ *
+ * @category Statement
+ */
+export interface GenericAtRuleRaws extends Omit {
+ /**
+ * Whether to collapse the nesting for an `@at-root` with no params that
+ * contains only a single style rule.
+ *
+ * This is ignored for rules that don't meet all of those criteria.
+ */
+ atRootShorthand?: boolean;
+}
+
+/**
+ * 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;
+ declare nodes: ChildNode[];
+
+ 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 {
+ if (this.name !== 'media' || !this.paramsInterpolation) {
+ return this.paramsInterpolation?.toString() ?? '';
+ }
+
+ // @media has special parsing in Sass, and allows raw expressions within
+ // parens.
+ let result = '';
+ const rawText = this.paramsInterpolation.raws.text;
+ const rawExpressions = this.paramsInterpolation.raws.expressions;
+ for (let i = 0; i < this.paramsInterpolation.nodes.length; i++) {
+ const element = this.paramsInterpolation.nodes[i];
+ if (typeof element === 'string') {
+ const raw = rawText?.[i];
+ result += raw?.value === element ? raw.raw : element;
+ } else {
+ if (result.match(/(\([ \t\n\f\r]*|(:|[<>]?=)[ \t\n\f\r]*)$/)) {
+ result += element;
+ } else {
+ const raw = rawExpressions?.[i];
+ result +=
+ '#{' + (raw?.before ?? '') + element + (raw?.after ?? '') + '}';
+ }
+ }
+ }
+ return result;
+ }
+ 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..905a3c072
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/index.ts
@@ -0,0 +1,297 @@
+// 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 {Node, NodeProps} from '../node';
+import * as sassInternal from '../sass-internal';
+import {CssComment, CssCommentProps} from './css-comment';
+import {SassComment, SassCommentChildProps} from './sass-comment';
+import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule';
+import {DebugRule, DebugRuleProps} from './debug-rule';
+import {EachRule, EachRuleProps} from './each-rule';
+import {ErrorRule, ErrorRuleProps} from './error-rule';
+import {ForRule, ForRuleProps} from './for-rule';
+import {Root} from './root';
+import {Rule, RuleProps} from './rule';
+
+// TODO: Replace this with the corresponding Sass types once they're
+// implemented.
+export {Declaration} from 'postcss';
+
+/**
+ * The union type of all Sass statements.
+ *
+ * @category Statement
+ */
+export type AnyStatement = Comment | 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'
+ | 'comment'
+ | 'debug-rule'
+ | 'each-rule'
+ | 'for-rule'
+ | 'error-rule'
+ | 'sass-comment';
+
+/**
+ * All Sass statements that are also at-rules.
+ *
+ * @category Statement
+ */
+export type AtRule = DebugRule | EachRule | ErrorRule | ForRule | GenericAtRule;
+
+/**
+ * All Sass statements that are comments.
+ *
+ * @category Statement
+ */
+export type Comment = CssComment | SassComment;
+
+/**
+ * All Sass statements that are valid children of other statements.
+ *
+ * The Sass equivalent of PostCSS's `ChildNode`.
+ *
+ * @category Statement
+ */
+export type ChildNode = Rule | AtRule | Comment;
+
+/**
+ * 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
+ | CssCommentProps
+ | DebugRuleProps
+ | EachRuleProps
+ | ErrorRuleProps
+ | ForRuleProps
+ | GenericAtRuleProps
+ | RuleProps
+ | SassCommentChildProps;
+
+/**
+ * The Sass eqivalent of PostCSS's `ContainerProps`.
+ *
+ * @category Statement
+ */
+export interface ContainerProps extends NodeProps {
+ nodes?: ReadonlyArray;
+}
+
+/**
+ * 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({
+ visitAtRootRule: inner => {
+ const rule = new GenericAtRule({
+ name: 'at-root',
+ paramsInterpolation: inner.query
+ ? new Interpolation(undefined, inner.query)
+ : undefined,
+ source: new LazySource(inner),
+ });
+ appendInternalChildren(rule, inner.children);
+ return rule;
+ },
+ visitAtRule: inner => new GenericAtRule(undefined, inner),
+ visitDebugRule: inner => new DebugRule(undefined, inner),
+ visitErrorRule: inner => new ErrorRule(undefined, inner),
+ visitEachRule: inner => new EachRule(undefined, inner),
+ visitForRule: inner => new ForRule(undefined, inner),
+ visitExtendRule: inner => {
+ const paramsInterpolation = new Interpolation(undefined, inner.selector);
+ if (inner.isOptional) paramsInterpolation.append('!optional');
+ return new GenericAtRule({
+ name: 'extend',
+ paramsInterpolation,
+ source: new LazySource(inner),
+ });
+ },
+ visitLoudComment: inner => new CssComment(undefined, inner),
+ visitMediaRule: inner => {
+ const rule = new GenericAtRule({
+ name: 'media',
+ paramsInterpolation: new Interpolation(undefined, inner.query),
+ source: new LazySource(inner),
+ });
+ appendInternalChildren(rule, inner.children);
+ return rule;
+ },
+ visitSilentComment: inner => new SassComment(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'] as (
+ nodes: postcss.NewChild,
+ sample: postcss.Node | undefined,
+ type?: 'prepend' | false
+) => postcss.ChildNode[];
+
+/**
+ * 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 as GenericAtRuleProps));
+ } else if ('debugExpression' in node) {
+ result.push(new DebugRule(node));
+ } else if ('eachExpression' in node) {
+ result.push(new EachRule(node));
+ } else if ('fromExpression' in node) {
+ result.push(new ForRule(node));
+ } else if ('errorExpression' in node) {
+ result.push(new ErrorRule(node));
+ } else if ('text' in node || 'textInterpolation' in node) {
+ result.push(new CssComment(node as CssCommentProps));
+ } else if ('silentText' in node) {
+ result.push(new SassComment(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/media-rule.test.ts b/pkg/sass-parser/lib/src/statement/media-rule.test.ts
new file mode 100644
index 000000000..7a2f8d9ac
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/media-rule.test.ts
@@ -0,0 +1,61 @@
+// 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, StringExpression, scss} from '../..';
+
+describe('a @media rule', () => {
+ let node: GenericAtRule;
+
+ describe('with no interpolation', () => {
+ beforeEach(
+ () =>
+ void (node = scss.parse('@media screen {}').nodes[0] as GenericAtRule)
+ );
+
+ it('has a name', () => expect(node.name).toBe('media'));
+
+ it('has a paramsInterpolation', () =>
+ expect(node).toHaveInterpolation('paramsInterpolation', 'screen'));
+
+ it('has matching params', () => expect(node.params).toBe('screen'));
+ });
+
+ // TODO: test a variable used directly without interpolation
+
+ describe('with interpolation', () => {
+ beforeEach(
+ () =>
+ void (node = scss.parse('@media (hover: #{hover}) {}')
+ .nodes[0] as GenericAtRule)
+ );
+
+ it('has a name', () => expect(node.name).toBe('media'));
+
+ it('has a paramsInterpolation', () => {
+ const params = node.paramsInterpolation!;
+ expect(params.nodes[0]).toBe('(');
+ expect(params).toHaveStringExpression(1, 'hover');
+ expect(params.nodes[2]).toBe(': ');
+ expect(params.nodes[3]).toBeInstanceOf(StringExpression);
+ expect((params.nodes[3] as StringExpression).text).toHaveStringExpression(
+ 0,
+ 'hover'
+ );
+ expect(params.nodes[4]).toBe(')');
+ });
+
+ it('has matching params', () =>
+ expect(node.params).toBe('(hover: #{hover})'));
+ });
+
+ describe('stringifies', () => {
+ // TODO: Use raws technology to include the actual original text between
+ // interpolations.
+ it('to SCSS', () =>
+ expect(
+ (node = scss.parse('@media #{screen} and (hover: #{hover}) {@foo}')
+ .nodes[0] as GenericAtRule).toString()
+ ).toBe('@media #{screen} and (hover: #{hover}) {\n @foo\n}'));
+ });
+});
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..975b19ed7
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/root-internal.d.ts
@@ -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 * as postcss from 'postcss';
+
+import {Rule} from './rule';
+import {Root, RootProps} from './root';
+import {AtRule, ChildNode, 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;
+ insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this;
+ insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this;
+ next(): ChildNode | undefined;
+ prepend(...nodes: NewNode[]): this;
+ prev(): ChildNode | undefined;
+ replaceWith(...nodes: NewNode[]): 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..31bef89ac
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/rule-internal.d.ts
@@ -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 * as postcss from 'postcss';
+
+import {Rule, RuleProps} from './rule';
+import {Root} from './root';
+import {AtRule, ChildNode, 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;
+ insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this;
+ insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this;
+ next(): ChildNode | undefined;
+ prepend(...nodes: NewNode[]): this;
+ prev(): ChildNode | undefined;
+ replaceWith(...nodes: NewNode[]): 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/statement/sass-comment.test.ts b/pkg/sass-parser/lib/src/statement/sass-comment.test.ts
new file mode 100644
index 000000000..cbd88598b
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/sass-comment.test.ts
@@ -0,0 +1,465 @@
+// 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 {Root, Rule, SassComment, sass, scss} from '../..';
+import * as utils from '../../../test/utils';
+
+describe('a Sass-style comment', () => {
+ let node: SassComment;
+ function describeNode(description: string, create: () => SassComment): void {
+ describe(description, () => {
+ beforeEach(() => void (node = create()));
+
+ it('has type comment', () => expect(node.type).toBe('comment'));
+
+ it('has sassType sass-comment', () =>
+ expect(node.sassType).toBe('sass-comment'));
+
+ it('has matching text', () => expect(node.text).toBe('foo\nbar'));
+
+ it('has matching silentText', () => expect(node.text).toBe('foo\nbar'));
+ });
+ }
+
+ describeNode(
+ 'parsed as SCSS',
+ () => scss.parse('// foo\n// bar').nodes[0] as SassComment
+ );
+
+ describeNode(
+ 'parsed as Sass',
+ () => sass.parse('// foo\n// bar').nodes[0] as SassComment
+ );
+
+ describeNode(
+ 'constructed manually',
+ () => new SassComment({text: 'foo\nbar'})
+ );
+
+ describeNode('constructed from ChildProps', () =>
+ utils.fromChildProps({silentText: 'foo\nbar'})
+ );
+
+ describe('parses raws', () => {
+ describe('in SCSS', () => {
+ it('with consistent whitespace before and after //', () => {
+ const node = scss.parse(' // foo\n // bar\n // baz')
+ .nodes[0] as SassComment;
+ expect(node.text).toEqual('foo\nbar\nbaz');
+ expect(node.raws).toEqual({
+ before: ' ',
+ beforeLines: ['', '', ''],
+ left: ' ',
+ });
+ });
+
+ it('with an empty line', () => {
+ const node = scss.parse('// foo\n//\n// baz').nodes[0] as SassComment;
+ expect(node.text).toEqual('foo\n\nbaz');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: ['', '', ''],
+ left: ' ',
+ });
+ });
+
+ it('with a line with only whitespace', () => {
+ const node = scss.parse('// foo\n// \t \n// baz')
+ .nodes[0] as SassComment;
+ expect(node.text).toEqual('foo\n \t \nbaz');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: ['', '', ''],
+ left: ' ',
+ });
+ });
+
+ it('with inconsistent whitespace before //', () => {
+ const node = scss.parse(' // foo\n // bar\n // baz')
+ .nodes[0] as SassComment;
+ expect(node.text).toEqual('foo\nbar\nbaz');
+ expect(node.raws).toEqual({
+ before: ' ',
+ beforeLines: [' ', '', ' '],
+ left: ' ',
+ });
+ });
+
+ it('with inconsistent whitespace types before //', () => {
+ const node = scss.parse(' \t// foo\n // bar').nodes[0] as SassComment;
+ expect(node.text).toEqual('foo\nbar');
+ expect(node.raws).toEqual({
+ before: ' ',
+ beforeLines: ['\t', ' '],
+ left: ' ',
+ });
+ });
+
+ it('with consistent whitespace types before //', () => {
+ const node = scss.parse(' \t// foo\n \t// bar').nodes[0] as SassComment;
+ expect(node.text).toEqual('foo\nbar');
+ expect(node.raws).toEqual({
+ before: ' \t',
+ beforeLines: ['', ''],
+ left: ' ',
+ });
+ });
+
+ it('with inconsistent whitespace after //', () => {
+ const node = scss.parse('// foo\n// bar\n// baz')
+ .nodes[0] as SassComment;
+ expect(node.text).toEqual(' foo\nbar\n baz');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: ['', '', ''],
+ left: ' ',
+ });
+ });
+
+ it('with inconsistent whitespace types after //', () => {
+ const node = scss.parse('// foo\n// \tbar').nodes[0] as SassComment;
+ expect(node.text).toEqual(' foo\n\tbar');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: ['', ''],
+ left: ' ',
+ });
+ });
+
+ it('with consistent whitespace types after //', () => {
+ const node = scss.parse('// \tfoo\n// \tbar').nodes[0] as SassComment;
+ expect(node.text).toEqual('foo\nbar');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: ['', ''],
+ left: ' \t',
+ });
+ });
+
+ it('with no text after //', () => {
+ const node = scss.parse('//').nodes[0] as SassComment;
+ expect(node.text).toEqual('');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: [''],
+ left: '',
+ });
+ });
+ });
+
+ describe('in Sass', () => {
+ it('with an empty line', () => {
+ const node = sass.parse('// foo\n//\n// baz').nodes[0] as SassComment;
+ expect(node.text).toEqual('foo\n\nbaz');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: ['', '', ''],
+ left: ' ',
+ });
+ });
+
+ it('with a line with only whitespace', () => {
+ const node = sass.parse('// foo\n// \t \n// baz')
+ .nodes[0] as SassComment;
+ expect(node.text).toEqual('foo\n \t \nbaz');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: ['', '', ''],
+ left: ' ',
+ });
+ });
+
+ it('with inconsistent whitespace after //', () => {
+ const node = sass.parse('// foo\n// bar\n// baz')
+ .nodes[0] as SassComment;
+ expect(node.text).toEqual(' foo\nbar\n baz');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: ['', '', ''],
+ left: ' ',
+ });
+ });
+
+ it('with inconsistent whitespace types after //', () => {
+ const node = sass.parse('// foo\n// \tbar').nodes[0] as SassComment;
+ expect(node.text).toEqual(' foo\n\tbar');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: ['', ''],
+ left: ' ',
+ });
+ });
+
+ it('with consistent whitespace types after //', () => {
+ const node = sass.parse('// \tfoo\n// \tbar').nodes[0] as SassComment;
+ expect(node.text).toEqual('foo\nbar');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: ['', ''],
+ left: ' \t',
+ });
+ });
+
+ it('with no text after //', () => {
+ const node = sass.parse('//').nodes[0] as SassComment;
+ expect(node.text).toEqual('');
+ expect(node.raws).toEqual({
+ before: '',
+ beforeLines: [''],
+ left: '',
+ });
+ });
+ });
+ });
+
+ describe('stringifies', () => {
+ describe('to SCSS', () => {
+ it('with default raws', () =>
+ expect(new SassComment({text: 'foo\nbar'}).toString()).toBe(
+ '// foo\n// bar'
+ ));
+
+ it('with left', () =>
+ expect(
+ new SassComment({
+ text: 'foo\nbar',
+ raws: {left: '\t'},
+ }).toString()
+ ).toBe('//\tfoo\n//\tbar'));
+
+ it('with left and an empty line', () =>
+ expect(
+ new SassComment({
+ text: 'foo\n\nbar',
+ raws: {left: '\t'},
+ }).toString()
+ ).toBe('//\tfoo\n//\n//\tbar'));
+
+ it('with left and a whitespace-only line', () =>
+ expect(
+ new SassComment({
+ text: 'foo\n \nbar',
+ raws: {left: '\t'},
+ }).toString()
+ ).toBe('//\tfoo\n// \n//\tbar'));
+
+ it('with before', () =>
+ expect(
+ new SassComment({
+ text: 'foo\nbar',
+ raws: {before: '\t'},
+ }).toString()
+ ).toBe('\t// foo\n\t// bar'));
+
+ it('with beforeLines', () =>
+ expect(
+ new Root({
+ nodes: [
+ new SassComment({
+ text: 'foo\nbar',
+ raws: {beforeLines: [' ', '\t']},
+ }),
+ ],
+ }).toString()
+ ).toBe(' // foo\n\t// bar'));
+
+ describe('with a following sibling', () => {
+ it('without before', () =>
+ expect(
+ new Root({
+ nodes: [{silentText: 'foo\nbar'}, {name: 'baz'}],
+ }).toString()
+ ).toBe('// foo\n// bar\n@baz'));
+
+ it('with before with newline', () =>
+ expect(
+ new Root({
+ nodes: [
+ {silentText: 'foo\nbar'},
+ {name: 'baz', raws: {before: '\n '}},
+ ],
+ }).toString()
+ ).toBe('// foo\n// bar\n @baz'));
+
+ it('with before without newline', () =>
+ expect(
+ new Root({
+ nodes: [
+ {silentText: 'foo\nbar'},
+ {name: 'baz', raws: {before: ' '}},
+ ],
+ }).toString()
+ ).toBe('// foo\n// bar\n @baz'));
+ });
+
+ describe('in a nested rule', () => {
+ it('without after', () =>
+ expect(
+ new Rule({
+ selector: '.zip',
+ nodes: [{silentText: 'foo\nbar'}],
+ }).toString()
+ ).toBe('.zip {\n // foo\n// bar\n}'));
+
+ it('with after with newline', () =>
+ expect(
+ new Rule({
+ selector: '.zip',
+ nodes: [{silentText: 'foo\nbar'}],
+ raws: {after: '\n '},
+ }).toString()
+ ).toBe('.zip {\n // foo\n// bar\n }'));
+
+ it('with after without newline', () =>
+ expect(
+ new Rule({
+ selector: '.zip',
+ nodes: [{silentText: 'foo\nbar'}],
+ raws: {after: ' '},
+ }).toString()
+ ).toBe('.zip {\n // foo\n// bar\n }'));
+ });
+ });
+ });
+
+ describe('assigned new text', () => {
+ beforeEach(() => {
+ node = scss.parse('// foo').nodes[0] as SassComment;
+ });
+
+ it('updates text', () => {
+ node.text = 'bar';
+ expect(node.text).toBe('bar');
+ });
+
+ it('updates silentText', () => {
+ node.text = 'bar';
+ expect(node.silentText).toBe('bar');
+ });
+ });
+
+ describe('assigned new silentText', () => {
+ beforeEach(() => {
+ node = scss.parse('// foo').nodes[0] as SassComment;
+ });
+
+ it('updates text', () => {
+ node.silentText = 'bar';
+ expect(node.text).toBe('bar');
+ });
+
+ it('updates silentText', () => {
+ node.silentText = 'bar';
+ expect(node.silentText).toBe('bar');
+ });
+ });
+
+ describe('clone', () => {
+ let original: SassComment;
+ beforeEach(
+ () => void (original = scss.parse('// foo').nodes[0] as SassComment)
+ );
+
+ describe('with no overrides', () => {
+ let clone: SassComment;
+ beforeEach(() => {
+ clone = original.clone();
+ });
+
+ describe('has the same properties:', () => {
+ it('text', () => expect(clone.text).toBe('foo'));
+
+ it('silentText', () => expect(clone.silentText).toBe('foo'));
+
+ it('raws', () =>
+ expect(clone.raws).toEqual({
+ before: '',
+ beforeLines: [''],
+ left: ' ',
+ }));
+
+ it('source', () => expect(clone.source).toBe(original.source));
+ });
+
+ describe('creates a new', () => {
+ it('self', () => expect(clone).not.toBe(original));
+
+ it('raws.beforeLines', () =>
+ expect(clone.raws.beforeLines).not.toBe(original.raws.beforeLines));
+
+ for (const attr of ['raws'] as const) {
+ it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
+ }
+ });
+ });
+
+ describe('overrides', () => {
+ describe('text', () => {
+ describe('defined', () => {
+ let clone: SassComment;
+ beforeEach(() => {
+ clone = original.clone({text: 'bar'});
+ });
+
+ it('changes text', () => expect(clone.text).toBe('bar'));
+
+ it('changes silentText', () => expect(clone.silentText).toBe('bar'));
+ });
+
+ describe('undefined', () => {
+ let clone: SassComment;
+ beforeEach(() => {
+ clone = original.clone({text: undefined});
+ });
+
+ it('preserves text', () => expect(clone.text).toBe('foo'));
+
+ it('preserves silentText', () =>
+ expect(clone.silentText).toBe('foo'));
+ });
+ });
+
+ describe('text', () => {
+ describe('defined', () => {
+ let clone: SassComment;
+ beforeEach(() => {
+ clone = original.clone({silentText: 'bar'});
+ });
+
+ it('changes text', () => expect(clone.text).toBe('bar'));
+
+ it('changes silentText', () => expect(clone.silentText).toBe('bar'));
+ });
+
+ describe('undefined', () => {
+ let clone: SassComment;
+ beforeEach(() => {
+ clone = original.clone({silentText: undefined});
+ });
+
+ it('preserves text', () => expect(clone.text).toBe('foo'));
+
+ it('preserves silentText', () =>
+ expect(clone.silentText).toBe('foo'));
+ });
+ });
+
+ describe('raws', () => {
+ it('defined', () =>
+ expect(original.clone({raws: {left: ' '}}).raws).toEqual({
+ left: ' ',
+ }));
+
+ it('undefined', () =>
+ expect(original.clone({raws: undefined}).raws).toEqual({
+ before: '',
+ beforeLines: [''],
+ left: ' ',
+ }));
+ });
+ });
+ });
+
+ it('toJSON', () => expect(scss.parse('// foo').nodes[0]).toMatchSnapshot());
+});
diff --git a/pkg/sass-parser/lib/src/statement/sass-comment.ts b/pkg/sass-parser/lib/src/statement/sass-comment.ts
new file mode 100644
index 000000000..1c8bb66ec
--- /dev/null
+++ b/pkg/sass-parser/lib/src/statement/sass-comment.ts
@@ -0,0 +1,182 @@
+// 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 {CommentRaws} from 'postcss/lib/comment';
+
+import {LazySource} from '../lazy-source';
+import type * as sassInternal from '../sass-internal';
+import {Interpolation} from '../interpolation';
+import * as utils from '../utils';
+import {ContainerProps, Statement, StatementWithChildren} from '.';
+import {_Comment} from './comment-internal';
+import {interceptIsClean} from './intercept-is-clean';
+import * as sassParser from '../..';
+
+/**
+ * The set of raws supported by {@link SassComment}.
+ *
+ * @category Statement
+ */
+export interface SassCommentRaws extends Omit {
+ /**
+ * Unlike PostCSS's, `CommentRaws.before`, this is added before `//` for
+ * _every_ line of this comment. If any lines have more indentation than this,
+ * it appears in {@link beforeLines} instead.
+ */
+ before?: string;
+
+ /**
+ * For each line in the comment, this is the whitespace that appears before
+ * the `//` _in addition to_ {@link before}.
+ */
+ beforeLines?: string[];
+
+ /**
+ * Unlike PostCSS's `CommentRaws.left`, this is added after `//` for _every_
+ * line in the comment that's not only whitespace. If any lines have more
+ * initial whitespace than this, it appears in {@link SassComment.text}
+ * instead.
+ *
+ * Lines that are only whitespace do not have `left` added to them, and
+ * instead have all their whitespace directly in {@link SassComment.text}.
+ */
+ left?: string;
+}
+
+/**
+ * The subset of {@link SassCommentProps} that can be used to construct it
+ * implicitly without calling `new SassComment()`.
+ *
+ * @category Statement
+ */
+export type SassCommentChildProps = ContainerProps & {
+ raws?: SassCommentRaws;
+ silentText: string;
+};
+
+/**
+ * The initializer properties for {@link SassComment}.
+ *
+ * @category Statement
+ */
+export type SassCommentProps = ContainerProps & {
+ raws?: SassCommentRaws;
+} & (
+ | {
+ silentText: string;
+ }
+ | {text: string}
+ );
+
+/**
+ * A Sass-style "silent" comment. Extends [`postcss.Comment`].
+ *
+ * [`postcss.Comment`]: https://postcss.org/api/#comment
+ *
+ * @category Statement
+ */
+export class SassComment
+ extends _Comment>
+ implements Statement
+{
+ readonly sassType = 'sass-comment' as const;
+ declare parent: StatementWithChildren | undefined;
+ declare raws: SassCommentRaws;
+
+ /**
+ * The text of this comment, potentially spanning multiple lines.
+ *
+ * This is always the same as {@link text}, it just has a different name to
+ * distinguish {@link SassCommentProps} from {@link CssCommentProps}.
+ */
+ declare silentText: string;
+
+ get text(): string {
+ return this.silentText;
+ }
+ set text(value: string) {
+ this.silentText = value;
+ }
+
+ constructor(defaults: SassCommentProps);
+ /** @hidden */
+ constructor(_: undefined, inner: sassInternal.SilentComment);
+ constructor(defaults?: SassCommentProps, inner?: sassInternal.SilentComment) {
+ super(defaults as unknown as postcss.CommentProps);
+
+ if (inner) {
+ this.source = new LazySource(inner);
+
+ const lineInfo = inner.text
+ .trimRight()
+ .split('\n')
+ .map(line => {
+ const index = line.indexOf('//');
+ const before = line.substring(0, index);
+ const regexp = /[^ \t]/g;
+ regexp.lastIndex = index + 2;
+ const firstNonWhitespace = regexp.exec(line)?.index;
+ if (firstNonWhitespace === undefined) {
+ return {before, left: null, text: line.substring(index + 2)};
+ }
+
+ const left = line.substring(index + 2, firstNonWhitespace);
+ const text = line.substring(firstNonWhitespace);
+ return {before, left, text};
+ });
+
+ // Dart Sass doesn't include the whitespace before the first `//` in
+ // SilentComment.text, so we grab it directly from the SourceFile.
+ let i = inner.span.start.offset - 1;
+ for (; i >= 0; i--) {
+ const char = inner.span.file.codeUnits[i];
+ if (char !== 0x20 && char !== 0x09) break;
+ }
+ lineInfo[0].before = inner.span.file.getText(
+ i + 1,
+ inner.span.start.offset
+ );
+
+ const before = (this.raws.before = utils.longestCommonInitialSubstring(
+ lineInfo.map(info => info.before)
+ ));
+ this.raws.beforeLines = lineInfo.map(info =>
+ info.before.substring(before.length)
+ );
+ const left = (this.raws.left = utils.longestCommonInitialSubstring(
+ lineInfo.map(info => info.left).filter(left => left !== null)
+ ));
+ this.text = lineInfo
+ .map(info => (info.left?.substring(left.length) ?? '') + info.text)
+ .join('\n');
+ }
+ }
+
+ clone(overrides?: Partial): this {
+ return utils.cloneNode(this, overrides, ['raws', 'silentText'], ['text']);
+ }
+
+ toJSON(): object;
+ /** @hidden */
+ toJSON(_: string, inputs: Map): object;
+ toJSON(_?: string, inputs?: Map): object {
+ return utils.toJSON(this, ['text', 'text'], inputs);
+ }
+
+ /** @hidden */
+ toString(
+ stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
+ .stringify
+ ): string {
+ return super.toString(stringifier);
+ }
+
+ /** @hidden */
+ get nonStatementChildren(): ReadonlyArray {
+ return [];
+ }
+}
+
+interceptIsClean(SassComment);
diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts
new file mode 100644
index 000000000..46374e19d
--- /dev/null
+++ b/pkg/sass-parser/lib/src/stringifier.ts
@@ -0,0 +1,183 @@
+// 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 {DebugRule} from './statement/debug-rule';
+import {EachRule} from './statement/each-rule';
+import {ErrorRule} from './statement/error-rule';
+import {GenericAtRule} from './statement/generic-at-rule';
+import {Rule} from './statement/rule';
+import {SassComment} from './statement/sass-comment';
+
+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 ['debug-rule'](node: DebugRule, semicolon: boolean): void {
+ this.builder(
+ '@debug' +
+ (node.raws.afterName ?? ' ') +
+ node.debugExpression +
+ (node.raws.between ?? '') +
+ (semicolon ? ';' : ''),
+ node
+ );
+ }
+
+ private ['each-rule'](node: EachRule): void {
+ this.block(
+ node,
+ '@each' +
+ (node.raws.afterName ?? ' ') +
+ node.params +
+ (node.raws.between ?? '')
+ );
+ }
+
+ private ['error-rule'](node: ErrorRule, semicolon: boolean): void {
+ this.builder(
+ '@error' +
+ (node.raws.afterName ?? ' ') +
+ node.errorExpression +
+ (node.raws.between ?? '') +
+ (semicolon ? ';' : ''),
+ node
+ );
+ }
+
+ private ['for-rule'](node: EachRule): void {
+ this.block(
+ node,
+ '@for' +
+ (node.raws.afterName ?? ' ') +
+ node.params +
+ (node.raws.between ?? '')
+ );
+ }
+
+ private atrule(node: GenericAtRule, semicolon: boolean): void {
+ // In the @at-root shorthand, stringify `@at-root {.foo {...}}` as
+ // `@at-root .foo {...}`.
+ if (
+ node.raws.atRootShorthand &&
+ node.name === 'at-root' &&
+ node.paramsInterpolation === undefined &&
+ node.nodes.length === 1 &&
+ node.nodes[0].sassType === 'rule'
+ ) {
+ this.block(
+ node.nodes[0],
+ '@at-root' +
+ (node.raws.afterName ?? ' ') +
+ node.nodes[0].selectorInterpolation
+ );
+ return;
+ }
+
+ const start =
+ `@${node.nameInterpolation}` +
+ (node.raws.afterName ?? (node.paramsInterpolation ? ' ' : '')) +
+ node.params;
+ 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());
+ }
+
+ private ['sass-comment'](node: SassComment): void {
+ const before = node.raws.before ?? '';
+ const left = node.raws.left ?? ' ';
+ let text = node.text
+ .split('\n')
+ .map(
+ (line, i) =>
+ before +
+ (node.raws.beforeLines?.[i] ?? '') +
+ '//' +
+ (/[^ \t]/.test(line) ? left : '') +
+ line
+ )
+ .join('\n');
+
+ // Ensure that a Sass-style comment always has a newline after it unless
+ // it's the last node in the document.
+ const next = node.next();
+ if (next && !this.raw(next, 'before').startsWith('\n')) {
+ text += '\n';
+ } else if (
+ !next &&
+ node.parent &&
+ !this.raw(node.parent, 'after').startsWith('\n')
+ ) {
+ text += '\n';
+ }
+
+ this.builder(text, node);
+ }
+}
diff --git a/pkg/sass-parser/lib/src/utils.ts b/pkg/sass-parser/lib/src/utils.ts
new file mode 100644
index 000000000..f022aca5d
--- /dev/null
+++ b/pkg/sass-parser/lib/src/utils.ts
@@ -0,0 +1,219 @@
+// 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;
+ }
+}
+
+/**
+ * Returns the longest string (of code units) that's an initial substring of
+ * every string in
+ * {@link strings}.
+ */
+export function longestCommonInitialSubstring(strings: string[]): string {
+ let candidate: string | undefined;
+ for (const string of strings) {
+ if (candidate === undefined) {
+ candidate = string;
+ } else {
+ for (let i = 0; i < candidate.length && i < string.length; i++) {
+ if (candidate.charCodeAt(i) !== string.charCodeAt(i)) {
+ candidate = candidate.substring(0, i);
+ break;
+ }
+ }
+ candidate = candidate.substring(
+ 0,
+ Math.min(candidate.length, string.length)
+ );
+ }
+ }
+ return candidate ?? '';
+}
diff --git a/pkg/sass-parser/package.json b/pkg/sass-parser/package.json
new file mode 100644
index 000000000..99e280a7f
--- /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": "file:../../build/npm"
+ },
+ "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 8061e65ab..6731f41e5 100644
--- a/pkg/sass_api/CHANGELOG.md
+++ b/pkg/sass_api/CHANGELOG.md
@@ -1,4 +1,4 @@
-## 11.0.0
+## 12.0.0
* **Breaking change:** Remove the `SassApiColor.hasCalculatedRgb` and
`.hasCalculatedHsl` extension methods. These can now be determined by checking
@@ -49,9 +49,14 @@
* Added `SassNumber.convertValueToUnit()` as a shorthand for
`SassNumber.convertValue()` with a single numerator.
-## 10.5.0
+## 11.1.0
-* No user-visible changes.
+* Loud comments in the Sass syntax no longer automatically inject ` */` to the
+ end when parsed.
+
+## 11.0.0
+
+* Remove the `CallableDeclaration()` constructor.
## 10.4.8
diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml
index 9912eecb6..26ae0c0bd 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: 11.0.0-dev
+version: 12.0.0
description: Additional APIs for Dart Sass.
homepage: https://github.com/sass/dart-sass
@@ -10,10 +10,10 @@ environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
- sass: 1.78.0
+ sass: 1.79.0
dev_dependencies:
- dartdoc: ">=6.0.0 <9.0.0"
+ dartdoc: ^8.0.14
dependency_overrides:
sass: { path: ../.. }
diff --git a/pubspec.yaml b/pubspec.yaml
index 5b278ec31..f9e04c1c3 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
name: sass
-version: 1.79.0-dev # TODO: update the color-4-api and color-functions deprecations when this is updated
+version: 1.79.0
description: A Sass implementation in Dart.
homepage: https://github.com/sass/dart-sass
@@ -17,7 +17,7 @@ dependencies:
cli_pkg: ^2.8.0
cli_repl: ^0.2.1
collection: ^1.16.0
- http: "^1.1.0"
+ http: ^1.1.0
js: ^0.6.3
meta: ^1.3.0
native_synchronization: ^0.3.0
@@ -32,21 +32,21 @@ 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
dev_dependencies:
- analyzer: ">=5.13.0 <7.0.0"
+ analyzer: ^6.8.0
archive: ^3.1.2
crypto: ^3.0.0
dart_style: ^2.0.0
- dartdoc: ">=6.0.0 <9.0.0"
+ dartdoc: ^8.0.14
grinder: ^0.9.0
node_preamble: ^2.0.2
- lints: ">=2.0.0 <5.0.0"
- protoc_plugin: ">=20.0.0 <22.0.0"
+ lints: ^4.0.0
+ protoc_plugin: ^21.1.2
pub_api_client: ^2.1.1
pubspec_parse: ^1.3.0
test: ^1.16.7
diff --git a/test/double_check_test.dart b/test/double_check_test.dart
index 6b8f67e96..89c44f82e 100644
--- a/test/double_check_test.dart
+++ b/test/double_check_test.dart
@@ -46,98 +46,128 @@ 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(
+ "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 615a22903..0cea7264b 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/double_check.dart b/tool/grind/double_check.dart
index 8b1dca18f..2578b5bf5 100644
--- a/tool/grind/double_check.dart
+++ b/tool/grind/double_check.dart
@@ -7,7 +7,6 @@ import 'dart:io';
import 'package:cli_pkg/cli_pkg.dart' as pkg;
import 'package:collection/collection.dart';
import 'package:grinder/grinder.dart';
-import 'package:path/path.dart' as p;
import 'package:pub_api_client/pub_api_client.dart';
import 'package:pubspec_parse/pubspec_parse.dart';
@@ -36,8 +35,10 @@ Future doubleCheckBeforeRelease() async {
".",
...Directory("pkg").listSync().map((entry) => entry.path)
]) {
- var pubspec = Pubspec.parse(File("$dir/pubspec.yaml").readAsStringSync(),
- sourceUrl: p.toUri("$dir/pubspec.yaml"));
+ var pubspecFile = File("$dir/pubspec.yaml");
+ if (!pubspecFile.existsSync()) continue;
+ var pubspec = Pubspec.parse(pubspecFile.readAsStringSync(),
+ sourceUrl: pubspecFile.uri);
var package = await client.packageInfo(pubspec.name);
if (pubspec.version == package.latestPubspec.version) {
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}.");
- }
- }
-}