diff --git a/lib/src/ast/sass/expression.dart b/lib/src/ast/sass/expression.dart index 5028d9f87..6a54fd26c 100644 --- a/lib/src/ast/sass/expression.dart +++ b/lib/src/ast/sass/expression.dart @@ -2,19 +2,18 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'package:charcode/charcode.dart'; import 'package:meta/meta.dart'; import '../../exception.dart'; import '../../logger.dart'; import '../../parse/scss.dart'; -import '../../util/nullable.dart'; -import '../../value.dart'; import '../../visitor/interface/expression.dart'; +import '../../visitor/is_calculation_safe.dart'; +import '../../visitor/source_interpolation.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. +// Note: 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. /// @@ -27,93 +26,30 @@ abstract class Expression implements SassNode { Expression(); - /// Parses an expression from [contents]. - /// - /// If passed, [url] is the name of the file from which [contents] comes. - /// - /// Throws a [SassFormatException] if parsing fails. - factory Expression.parse(String contents, {Object? url, Logger? logger}) => - ScssParser(contents, url: url, logger: logger).parseExpression(); -} - -// Use an extension class rather than a method so we don't have to make -// [Expression] a concrete base class for something we'll get rid of anyway once -// we remove the global math functions that make this necessary. -extension ExpressionExtensions on Expression { /// Whether this expression can be used in a calculation context. /// /// @nodoc @internal - bool get isCalculationSafe => accept(_IsCalculationSafeVisitor()); -} - -// We could use [AstSearchVisitor] to implement this more tersely, but that -// would default to returning `true` if we added a new expression type and -// forgot to update this class. -class _IsCalculationSafeVisitor implements ExpressionVisitor { - const _IsCalculationSafeVisitor(); - - bool visitBinaryOperationExpression(BinaryOperationExpression node) => - (const { - BinaryOperator.times, - BinaryOperator.dividedBy, - BinaryOperator.plus, - BinaryOperator.minus - }).contains(node.operator) && - (node.left.accept(this) || node.right.accept(this)); - - bool visitBooleanExpression(BooleanExpression node) => false; - - bool visitColorExpression(ColorExpression node) => false; - - bool visitFunctionExpression(FunctionExpression node) => true; - - bool visitInterpolatedFunctionExpression( - InterpolatedFunctionExpression node) => - true; - - bool visitIfExpression(IfExpression node) => true; - - bool visitListExpression(ListExpression node) => - node.separator == ListSeparator.space && - !node.hasBrackets && - node.contents.length > 1 && - node.contents.every((expression) => expression.accept(this)); - - bool visitMapExpression(MapExpression node) => false; + bool get isCalculationSafe => accept(const IsCalculationSafeVisitor()); - bool visitNullExpression(NullExpression node) => false; - - bool visitNumberExpression(NumberExpression node) => true; - - bool visitParenthesizedExpression(ParenthesizedExpression node) => - node.expression.accept(this); - - bool visitSelectorExpression(SelectorExpression node) => false; - - bool visitStringExpression(StringExpression node) { - if (node.hasQuotes) return false; - - // Exclude non-identifier constructs that are parsed as [StringExpression]s. - // We could just check if they parse as valid identifiers, but this is - // cheaper. - var text = node.text.initialPlain; - return - // !important - !text.startsWith("!") && - // ID-style identifiers - !text.startsWith("#") && - // Unicode ranges - text.codeUnitAtOrNull(1) != $plus && - // url() - text.codeUnitAtOrNull(3) != $lparen; + /// If this expression is valid interpolated plain CSS, returns the equivalent + /// of parsing its source as an interpolated unknown value. + /// + /// Otherwise, returns null. + /// + /// @nodoc + @internal + Interpolation? get sourceInterpolation { + var visitor = SourceInterpolationVisitor(); + accept(visitor); + return visitor.buffer?.interpolation(span); } - bool visitSupportsExpression(SupportsExpression node) => false; - - bool visitUnaryOperationExpression(UnaryOperationExpression node) => false; - - bool visitValueExpression(ValueExpression node) => false; - - bool visitVariableExpression(VariableExpression node) => true; + /// Parses an expression from [contents]. + /// + /// If passed, [url] is the name of the file from which [contents] comes. + /// + /// Throws a [SassFormatException] if parsing fails. + factory Expression.parse(String contents, {Object? url, Logger? logger}) => + ScssParser(contents, url: url, logger: logger).parseExpression(); } diff --git a/lib/src/ast/sass/expression/string.dart b/lib/src/ast/sass/expression/string.dart index cddb9e848..f9f05d98e 100644 --- a/lib/src/ast/sass/expression/string.dart +++ b/lib/src/ast/sass/expression/string.dart @@ -44,7 +44,7 @@ final class StringExpression extends Expression { /// Returns a string expression with no interpolation. StringExpression.plain(String text, FileSpan span, {bool quotes = false}) - : text = Interpolation([text], span), + : text = Interpolation.plain(text, span), hasQuotes = quotes; T accept(ExpressionVisitor visitor) => @@ -64,11 +64,12 @@ final class StringExpression extends Expression { quote ??= _bestQuote(text.contents.whereType()); var buffer = InterpolationBuffer(); buffer.writeCharCode(quote); - for (var value in text.contents) { + for (var i = 0; i < text.contents.length; i++) { + var value = text.contents[i]; assert(value is Expression || value is String); switch (value) { case Expression(): - buffer.add(value); + buffer.add(value, text.spanForElement(i)); case String(): _quoteInnerText(value, quote, buffer, static: static); } diff --git a/lib/src/ast/sass/interpolation.dart b/lib/src/ast/sass/interpolation.dart index 075b3344f..345819d21 100644 --- a/lib/src/ast/sass/interpolation.dart +++ b/lib/src/ast/sass/interpolation.dart @@ -5,7 +5,6 @@ import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; -import '../../interpolation_buffer.dart'; import 'expression.dart'; import 'node.dart'; @@ -19,6 +18,15 @@ final class Interpolation implements SassNode { /// [String]s. final List contents; + /// The source spans for each [Expression] in [contents]. + /// + /// Unlike [Expression.span], which just covers the expresssion itself, this + /// should go from `#{` through `}`. + /// + /// @nodoc + @internal + final List spans; + final FileSpan span; /// Returns whether this contains no interpolated expressions. @@ -37,42 +45,62 @@ final class Interpolation implements SassNode { String get initialPlain => switch (contents) { [String first, ...] => first, _ => '' }; - /// Creates a new [Interpolation] by concatenating a sequence of [String]s, - /// [Expression]s, or nested [Interpolation]s. - static Interpolation concat( - Iterable contents, - FileSpan span) { - var buffer = InterpolationBuffer(); - for (var element in contents) { - switch (element) { - case String(): - buffer.write(element); - case Expression(): - buffer.add(element); - case Interpolation(): - buffer.addInterpolation(element); - case _: - throw ArgumentError.value(contents, "contents", - "May only contains Strings, Expressions, or Interpolations."); - } - } + /// Returns the [FileSpan] covering the element of the interpolation at + /// [index]. + /// + /// Unlike `contents[index].span`, which only covers the text of the + /// expression itself, this typically covers the entire `#{}` that surrounds + /// the expression. However, this is not a strong guarantee—there are cases + /// where interpolations are constructed when the source uses Sass expressions + /// directly where this may return the same value as `contents[index].span`. + /// + /// For string elements, this is the span that covers the entire text of the + /// string, including the quote for text at the beginning or end of quoted + /// strings. Note that the quote is *never* included for expressions. + FileSpan spanForElement(int index) => switch (contents[index]) { + String() => span.file.span( + (index == 0 ? span.start : spans[index - 1]!.end).offset, + (index == spans.length ? span.end : spans[index + 1]!.start) + .offset), + _ => spans[index]! + }; - return buffer.interpolation(span); - } + Interpolation.plain(String text, this.span) + : contents = List.unmodifiable([text]), + spans = const [null]; + + /// Creates a new [Interpolation] with the given [contents]. + /// + /// The [spans] must include a [FileSpan] for each [Expression] in [contents]. + /// These spans should generally cover the entire `#{}` surrounding the + /// expression. + /// + /// The single [span] must cover the entire interpolation. + Interpolation(Iterable contents, + Iterable spans, this.span) + : contents = List.unmodifiable(contents), + spans = List.unmodifiable(spans) { + if (spans.length != contents.length) { + throw ArgumentError.value( + this.spans, "spans", "Must be the same length as contents."); + } - Interpolation(Iterable contents, this.span) - : contents = List.unmodifiable(contents) { for (var i = 0; i < this.contents.length; i++) { - if (this.contents[i] is! String && this.contents[i] is! Expression) { + var isString = this.contents[i] is String; + if (!isString && this.contents[i] is! Expression) { throw ArgumentError.value(this.contents, "contents", - "May only contains Strings or Expressions."); - } - - if (i != 0 && - this.contents[i - 1] is String && - this.contents[i] is String) { - throw ArgumentError.value( - this.contents, "contents", "May not contain adjacent Strings."); + "May only contain Strings or Expressions."); + } else if (isString) { + if (i != 0 && this.contents[i - 1] is String) { + throw ArgumentError.value( + this.contents, "contents", "May not contain adjacent Strings."); + } else if (i < spans.length && this.spans[i] != null) { + throw ArgumentError.value(this.spans, "spans", + "May not have a value for string elements (at index $i)."); + } + } else if (i >= spans.length || this.spans[i] == null) { + throw ArgumentError.value(this.spans, "spans", + "Must not have a value for expression elements (at index $i)."); } } } diff --git a/lib/src/ast/sass/supports_condition.dart b/lib/src/ast/sass/supports_condition.dart index 4b38d304e..e078c955e 100644 --- a/lib/src/ast/sass/supports_condition.dart +++ b/lib/src/ast/sass/supports_condition.dart @@ -2,9 +2,26 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; + +import 'interpolation.dart'; import 'node.dart'; /// An abstract class for defining the condition a `@supports` rule selects. /// /// {@category AST} -abstract interface class SupportsCondition implements SassNode {} +abstract interface class SupportsCondition implements SassNode { + /// Converts this condition into an interpolation that produces the same + /// value. + /// + /// @nodoc + @internal + Interpolation toInterpolation(); + + /// Returns a copy of this condition with [span] as its span. + /// + /// @nodoc + @internal + SupportsCondition withSpan(FileSpan span); +} diff --git a/lib/src/ast/sass/supports_condition/anything.dart b/lib/src/ast/sass/supports_condition/anything.dart index 91d90024a..dae51e424 100644 --- a/lib/src/ast/sass/supports_condition/anything.dart +++ b/lib/src/ast/sass/supports_condition/anything.dart @@ -3,7 +3,10 @@ // https://opensource.org/licenses/MIT. import 'package:source_span/source_span.dart'; +import 'package:meta/meta.dart'; +import '../../../interpolation_buffer.dart'; +import '../../../util/span.dart'; import '../interpolation.dart'; import '../supports_condition.dart'; @@ -19,5 +22,17 @@ final class SupportsAnything implements SupportsCondition { SupportsAnything(this.contents, this.span); + /// @nodoc + @internal + Interpolation toInterpolation() => (InterpolationBuffer() + ..write(span.before(contents.span).text) + ..addInterpolation(contents) + ..write(span.after(contents.span).text)) + .interpolation(span); + + /// @nodoc + @internal + SupportsAnything withSpan(FileSpan span) => SupportsAnything(contents, span); + String toString() => "($contents)"; } diff --git a/lib/src/ast/sass/supports_condition/declaration.dart b/lib/src/ast/sass/supports_condition/declaration.dart index 322731018..f81d37f2c 100644 --- a/lib/src/ast/sass/supports_condition/declaration.dart +++ b/lib/src/ast/sass/supports_condition/declaration.dart @@ -5,8 +5,11 @@ import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; +import '../../../interpolation_buffer.dart'; +import '../../../util/span.dart'; import '../expression.dart'; import '../expression/string.dart'; +import '../interpolation.dart'; import '../supports_condition.dart'; /// A condition that selects for browsers where a given declaration is @@ -40,5 +43,32 @@ final class SupportsDeclaration implements SupportsCondition { SupportsDeclaration(this.name, this.value, this.span); + /// @nodoc + @internal + Interpolation toInterpolation() { + var buffer = InterpolationBuffer(); + buffer.write(span.before(name.span).text); + if (name case StringExpression(hasQuotes: false, :var text)) { + buffer.addInterpolation(text); + } else { + buffer.add(name, name.span); + } + + buffer.write(name.span.between(value.span).text); + if (value.sourceInterpolation case var interpolation?) { + buffer.addInterpolation(interpolation); + } else { + buffer.add(value, value.span); + } + + buffer.write(span.after(value.span).text); + return buffer.interpolation(span); + } + + /// @nodoc + @internal + SupportsDeclaration withSpan(FileSpan span) => + SupportsDeclaration(name, value, span); + String toString() => "($name: $value)"; } diff --git a/lib/src/ast/sass/supports_condition/function.dart b/lib/src/ast/sass/supports_condition/function.dart index dd9ac5b29..f31a4d054 100644 --- a/lib/src/ast/sass/supports_condition/function.dart +++ b/lib/src/ast/sass/supports_condition/function.dart @@ -2,8 +2,11 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; +import '../../../interpolation_buffer.dart'; +import '../../../util/span.dart'; import '../interpolation.dart'; import '../supports_condition.dart'; @@ -21,5 +24,19 @@ final class SupportsFunction implements SupportsCondition { SupportsFunction(this.name, this.arguments, this.span); + /// @nodoc + @internal + Interpolation toInterpolation() => (InterpolationBuffer() + ..addInterpolation(name) + ..write(name.span.between(arguments.span).text) + ..addInterpolation(arguments) + ..write(span.after(arguments.span).text)) + .interpolation(span); + + /// @nodoc + @internal + SupportsFunction withSpan(FileSpan span) => + SupportsFunction(name, arguments, span); + String toString() => "$name($arguments)"; } diff --git a/lib/src/ast/sass/supports_condition/interpolation.dart b/lib/src/ast/sass/supports_condition/interpolation.dart index 839fccf9f..4814826ec 100644 --- a/lib/src/ast/sass/supports_condition/interpolation.dart +++ b/lib/src/ast/sass/supports_condition/interpolation.dart @@ -2,9 +2,11 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; import '../expression.dart'; +import '../interpolation.dart'; import '../supports_condition.dart'; /// An interpolated condition. @@ -18,5 +20,14 @@ final class SupportsInterpolation implements SupportsCondition { SupportsInterpolation(this.expression, this.span); + /// @nodoc + @internal + Interpolation toInterpolation() => Interpolation([expression], [span], span); + + /// @nodoc + @internal + SupportsInterpolation withSpan(FileSpan span) => + SupportsInterpolation(expression, span); + String toString() => "#{$expression}"; } diff --git a/lib/src/ast/sass/supports_condition/negation.dart b/lib/src/ast/sass/supports_condition/negation.dart index 23cd7193e..7658c868e 100644 --- a/lib/src/ast/sass/supports_condition/negation.dart +++ b/lib/src/ast/sass/supports_condition/negation.dart @@ -2,8 +2,12 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; +import '../../../interpolation_buffer.dart'; +import '../../../util/span.dart'; +import '../interpolation.dart'; import '../supports_condition.dart'; import 'operation.dart'; @@ -18,6 +22,18 @@ final class SupportsNegation implements SupportsCondition { SupportsNegation(this.condition, this.span); + /// @nodoc + @internal + Interpolation toInterpolation() => (InterpolationBuffer() + ..write(span.before(condition.span).text) + ..addInterpolation(condition.toInterpolation()) + ..write(span.after(condition.span).text)) + .interpolation(span); + + /// @nodoc + @internal + SupportsNegation withSpan(FileSpan span) => SupportsNegation(condition, span); + String toString() { if (condition is SupportsNegation || condition is SupportsOperation) { return "not ($condition)"; diff --git a/lib/src/ast/sass/supports_condition/operation.dart b/lib/src/ast/sass/supports_condition/operation.dart index f072fc2e3..71fed25c9 100644 --- a/lib/src/ast/sass/supports_condition/operation.dart +++ b/lib/src/ast/sass/supports_condition/operation.dart @@ -2,8 +2,12 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; +import '../../../interpolation_buffer.dart'; +import '../../../util/span.dart'; +import '../interpolation.dart'; import '../supports_condition.dart'; import 'negation.dart'; @@ -32,6 +36,21 @@ final class SupportsOperation implements SupportsCondition { } } + /// @nodoc + @internal + Interpolation toInterpolation() => (InterpolationBuffer() + ..write(span.before(left.span).text) + ..addInterpolation(left.toInterpolation()) + ..write(left.span.between(right.span).text) + ..addInterpolation(right.toInterpolation()) + ..write(span.after(right.span).text)) + .interpolation(span); + + /// @nodoc + @internal + SupportsOperation withSpan(FileSpan span) => + SupportsOperation(left, right, operator, span); + String toString() => "${_parenthesize(left)} $operator ${_parenthesize(right)}"; diff --git a/lib/src/interpolation_buffer.dart b/lib/src/interpolation_buffer.dart index 014c31fec..e80080a68 100644 --- a/lib/src/interpolation_buffer.dart +++ b/lib/src/interpolation_buffer.dart @@ -20,6 +20,12 @@ final class InterpolationBuffer implements StringSink { /// This contains [String]s and [Expression]s. final _contents = []; + /// The spans of the expressions in [_contents]. + /// + /// These spans cover from the beginning to the end of `#{}`, rather than just + /// the expressions themselves. + final _spans = []; + /// Returns whether this buffer has no contents. bool get isEmpty => _contents.isEmpty && _text.isEmpty; @@ -39,9 +45,12 @@ final class InterpolationBuffer implements StringSink { void writeln([Object? obj = '']) => _text.writeln(obj); /// Adds [expression] to this buffer. - void add(Expression expression) { + /// + /// The [span] should cover from the beginning of `#{` through `}`. + void add(Expression expression, FileSpan span) { _flushText(); _contents.add(expression); + _spans.add(span); } /// Adds the contents of [interpolation] to this buffer. @@ -49,28 +58,37 @@ final class InterpolationBuffer implements StringSink { if (interpolation.contents.isEmpty) return; Iterable toAdd = interpolation.contents; + Iterable spansToAdd = interpolation.spans; if (interpolation.contents case [String first, ...var rest]) { _text.write(first); toAdd = rest; + assert(interpolation.spans.first == null); + spansToAdd = interpolation.spans.skip(1); } _flushText(); _contents.addAll(toAdd); - if (_contents.last is String) _text.write(_contents.removeLast()); + _spans.addAll(spansToAdd); + if (_contents.last is String) { + _text.write(_contents.removeLast()); + var lastSpan = _spans.removeLast(); + assert(lastSpan == null); + } } /// Flushes [_text] to [_contents] if necessary. void _flushText() { if (_text.isEmpty) return; _contents.add(_text.toString()); + _spans.add(null); _text.clear(); } /// Creates an [Interpolation] with the given [span] from the contents of this /// buffer. Interpolation interpolation(FileSpan span) { - return Interpolation( - [..._contents, if (_text.isNotEmpty) _text.toString()], span); + return Interpolation([..._contents, if (_text.isNotEmpty) _text.toString()], + [..._spans, if (_text.isNotEmpty) null], span); } String toString() { diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart index 7dbe81c81..2f4c75109 100644 --- a/lib/src/js/parser.dart +++ b/lib/src/js/parser.dart @@ -35,6 +35,14 @@ class ParserExports { external set createExpressionVisitor(Function function); } +/// An empty interpolation, used to initialize empty AST entries to modify their +/// prototypes. +final _interpolation = Interpolation(const [], const [], bogusSpan); + +/// An expression used to initialize empty AST entries to modify their +/// prototypes. +final _expression = NullExpression(bogusSpan); + /// Loads and returns all the exports needed for the `sass-parser` package. ParserExports loadParserExports() { _updateAstPrototypes(); @@ -59,29 +67,49 @@ void _updateAstPrototypes() { (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) + getJSClass(_interpolation) .defineGetter('asPlain', (Interpolation self) => self.asPlain); - getJSClass(ExtendRule(interpolation, bogusSpan)).superclass.defineMethod( + getJSClass(ExtendRule(_interpolation, bogusSpan)).superclass.defineMethod( 'accept', (Statement self, StatementVisitor visitor) => self.accept(visitor)); - var string = StringExpression(interpolation); + var string = StringExpression(_interpolation); getJSClass(string).superclass.defineMethod( 'accept', (Expression self, ExpressionVisitor visitor) => self.accept(visitor)); + _addSupportsConditionToInterpolation(); + for (var node in [ string, BinaryOperationExpression(BinaryOperator.plus, string, string), - SupportsExpression(SupportsAnything(interpolation, bogusSpan)), - LoudComment(interpolation) + SupportsExpression(SupportsAnything(_interpolation, bogusSpan)), + LoudComment(_interpolation) ]) { getJSClass(node).defineGetter('span', (SassNode self) => self.span); } } +/// Updates the prototypes of [SupportsCondition] AST types to support +/// converting them to an [Interpolation] for the JS API. +/// +/// Works around sass/sass#3935. +void _addSupportsConditionToInterpolation() { + var anything = SupportsAnything(_interpolation, bogusSpan); + for (var node in [ + anything, + SupportsDeclaration(_expression, _expression, bogusSpan), + SupportsFunction(_interpolation, _interpolation, bogusSpan), + SupportsInterpolation(_expression, bogusSpan), + SupportsNegation(anything, bogusSpan), + SupportsOperation(anything, anything, "and", bogusSpan) + ]) { + getJSClass(node).defineMethod( + 'toInterpolation', (SupportsCondition self) => self.toInterpolation()); + } +} + /// A JavaScript-friendly method to parse a stylesheet. Stylesheet _parse(String css, String syntax, String? path, JSLogger? logger) => Stylesheet.parse( diff --git a/lib/src/parse/css.dart b/lib/src/parse/css.dart index 22d380edb..fa48e635f 100644 --- a/lib/src/parse/css.dart +++ b/lib/src/parse/css.dart @@ -86,17 +86,15 @@ class CssParser extends ScssParser { ImportRule _cssImportRule(LineScannerState start) { var urlStart = scanner.state; var url = switch (scanner.peekChar()) { - $u || $U => dynamicUrl(), + $u || $U => dynamicUrl() as StringExpression, _ => StringExpression(interpolatedString().asInterpolation(static: true)) }; - var urlSpan = scanner.spanFrom(urlStart); whitespace(); var modifiers = tryImportModifiers(); expectStatementSeparator("@import rule"); return ImportRule([ - StaticImport(Interpolation([url], urlSpan), scanner.spanFrom(urlStart), - modifiers: modifiers) + StaticImport(url.text, scanner.spanFrom(urlStart), modifiers: modifiers) ], scanner.spanFrom(start)); } diff --git a/lib/src/parse/sass.dart b/lib/src/parse/sass.dart index 689131bf1..ba9e22411 100644 --- a/lib/src/parse/sass.dart +++ b/lib/src/parse/sass.dart @@ -98,7 +98,7 @@ class SassParser extends StylesheetParser { // Serialize [url] as a Sass string because [StaticImport] expects it to // include quotes. return StaticImport( - Interpolation([SassString(url).toString()], span), span); + Interpolation.plain(SassString(url).toString(), span), span); } else { try { return DynamicImport(parseImportUrl(url), span); @@ -247,7 +247,8 @@ class SassParser extends StylesheetParser { case $hash: if (scanner.peekChar(1) == $lbrace) { - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); } else { buffer.writeCharCode(scanner.readChar()); } diff --git a/lib/src/parse/scss.dart b/lib/src/parse/scss.dart index 67b5c0f4b..e9b9693f3 100644 --- a/lib/src/parse/scss.dart +++ b/lib/src/parse/scss.dart @@ -155,7 +155,8 @@ class ScssParser extends StylesheetParser { switch (scanner.peekChar()) { case $hash: if (scanner.peekChar(1) == $lbrace) { - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); } else { buffer.writeCharCode(scanner.readChar()); } diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index b619949b0..df28da881 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -1084,7 +1084,10 @@ abstract class StylesheetParser extends Parser { var url = dynamicUrl(); whitespace(); var modifiers = tryImportModifiers(); - return StaticImport(Interpolation([url], scanner.spanFrom(start)), + return StaticImport( + url is StringExpression + ? url.text + : Interpolation([url], [url.span], url.span), scanner.spanFrom(start), modifiers: modifiers); } @@ -1095,7 +1098,7 @@ abstract class StylesheetParser extends Parser { var modifiers = tryImportModifiers(); if (isPlainImportUrl(url) || modifiers != null) { return StaticImport( - Interpolation([urlSpan.text], urlSpan), scanner.spanFrom(start), + Interpolation.plain(urlSpan.text, urlSpan), scanner.spanFrom(start), modifiers: modifiers); } else { try { @@ -1158,7 +1161,7 @@ abstract class StylesheetParser extends Parser { if (name == "supports") { var query = _importSupportsQuery(); if (query is! SupportsDeclaration) buffer.writeCharCode($lparen); - buffer.add(SupportsExpression(query)); + buffer.add(SupportsExpression(query), query.span); if (query is! SupportsDeclaration) buffer.writeCharCode($rparen); } else { buffer.writeCharCode($lparen); @@ -1203,7 +1206,8 @@ abstract class StylesheetParser extends Parser { var start = scanner.state; var name = _expression(); scanner.expectChar($colon); - return _supportsDeclarationValue(name, start); + return SupportsDeclaration( + name, _supportsDeclarationValue(name), scanner.spanFrom(start)); } } @@ -1337,7 +1341,8 @@ abstract class StylesheetParser extends Parser { var needsDeprecationWarning = false; while (true) { if (scanner.peekChar() == $hash) { - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); needsDeprecationWarning = true; } else { var identifierStart = scanner.state; @@ -2542,7 +2547,8 @@ abstract class StylesheetParser extends Parser { buffer.writeCharCode(escapeCharacter()); } case $hash when scanner.peekChar(1) == $lbrace: - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); case _: buffer.writeCharCode(scanner.readChar()); } @@ -2705,7 +2711,8 @@ abstract class StylesheetParser extends Parser { case $backslash: buffer.write(escape()); case $hash when scanner.peekChar(1) == $lbrace: - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); case $exclamation || $percent || $ampersand || @@ -2740,7 +2747,7 @@ abstract class StylesheetParser extends Parser { } return InterpolatedFunctionExpression( - Interpolation(["url"], scanner.spanFrom(start)), + Interpolation.plain("url", scanner.spanFrom(start)), _argumentInvocation(), scanner.spanFrom(start)); } @@ -3010,7 +3017,8 @@ abstract class StylesheetParser extends Parser { case $backslash: buffer.write(escape(identifierStart: true)); case $hash when scanner.peekChar(1) == $lbrace: - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); case _: scanner.error("Expected identifier."); } @@ -3032,28 +3040,30 @@ abstract class StylesheetParser extends Parser { case $backslash: buffer.write(escape()); case $hash when scanner.peekChar(1) == $lbrace: - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); case _: break loop; } } } - /// Consumes interpolation. + /// Consumes interpolation and returns it along with the span covering the + /// `#{}`. @protected - Expression singleInterpolation() { + (Expression, FileSpan span) singleInterpolation() { var start = scanner.state; scanner.expect('#{'); whitespace(); var contents = _expression(); scanner.expectChar($rbrace); + var span = scanner.spanFrom(start); if (plainCss) { - error( - "Interpolation isn't allowed in plain CSS.", scanner.spanFrom(start)); + error("Interpolation isn't allowed in plain CSS.", span); } - return contents; + return (contents, span); } // ## Media Queries @@ -3165,9 +3175,8 @@ abstract class StylesheetParser extends Parser { /// Consumes a `MediaOrInterp` expression and writes it to [buffer]. void _mediaOrInterp(InterpolationBuffer buffer) { if (scanner.peekChar() == $hash) { - var interpolation = singleInterpolation(); - buffer - .addInterpolation(Interpolation([interpolation], interpolation.span)); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); } else { _mediaInParens(buffer); } @@ -3196,12 +3205,14 @@ abstract class StylesheetParser extends Parser { expectWhitespace(); _mediaOrInterp(buffer); } else { - buffer.add(_expressionUntilComparison()); + var expressionBefore = _expressionUntilComparison(); + buffer.add(expressionBefore, expressionBefore.span); if (scanner.scanChar($colon)) { whitespace(); buffer.writeCharCode($colon); buffer.writeCharCode($space); - buffer.add(_expression()); + var expressionAfter = _expression(); + buffer.add(expressionAfter, expressionAfter.span); } else { var next = scanner.peekChar(); if (next case $langle || $rangle || $equal) { @@ -3213,7 +3224,8 @@ abstract class StylesheetParser extends Parser { buffer.writeCharCode($space); whitespace(); - buffer.add(_expressionUntilComparison()); + var expressionMiddle = _expressionUntilComparison(); + buffer.add(expressionMiddle, expressionMiddle.span); // dart-lang/sdk#45356 if (next case $langle || $rangle when scanner.scanChar(next!)) { @@ -3223,7 +3235,8 @@ abstract class StylesheetParser extends Parser { buffer.writeCharCode($space); whitespace(); - buffer.add(_expressionUntilComparison()); + var expressionAfter = _expressionUntilComparison(); + buffer.add(expressionAfter, expressionAfter.span); } } } @@ -3308,7 +3321,7 @@ abstract class StylesheetParser extends Parser { } else if (scanner.peekChar() == $lparen) { var condition = _supportsCondition(); scanner.expectChar($rparen); - return condition; + return condition.withSpan(scanner.spanFrom(start)); } // Unfortunately, we may have to backtrack here. The grammar is: @@ -3338,7 +3351,7 @@ abstract class StylesheetParser extends Parser { var identifier = interpolatedIdentifier(); if (_trySupportsOperation(identifier, nameStart) case var operation?) { scanner.expectChar($rparen); - return operation; + return operation.withSpan(scanner.spanFrom(start)); } // If parsing an expression fails, try to parse an @@ -3356,24 +3369,21 @@ abstract class StylesheetParser extends Parser { return SupportsAnything(contents, scanner.spanFrom(start)); } - var declaration = _supportsDeclarationValue(name, start); + var value = _supportsDeclarationValue(name); scanner.expectChar($rparen); - return declaration; + return SupportsDeclaration(name, value, scanner.spanFrom(start)); } /// Parses and returns the right-hand side of a declaration in a supports /// query. - SupportsDeclaration _supportsDeclarationValue( - Expression name, LineScannerState start) { - Expression value; + Expression _supportsDeclarationValue(Expression name) { if (name case StringExpression(hasQuotes: false, :var text) when text.initialPlain.startsWith("--")) { - value = StringExpression(_interpolatedDeclarationValue()); + return StringExpression(_interpolatedDeclarationValue()); } else { whitespace(); - value = _expression(); + return _expression(); } - return SupportsDeclaration(name, value, scanner.spanFrom(start)); } /// If [interpolation] is followed by `"and"` or `"or"`, parse it as a supports operation. @@ -3530,7 +3540,7 @@ abstract class StylesheetParser extends Parser { if (expression is StringExpression && !expression.hasQuotes) { buffer.addInterpolation(expression.text); } else { - buffer.add(expression); + buffer.add(expression, expression.span); } } diff --git a/lib/src/util/span.dart b/lib/src/util/span.dart index 6328f4aed..7d84cbd63 100644 --- a/lib/src/util/span.dart +++ b/lib/src/util/span.dart @@ -84,6 +84,50 @@ extension SpanExtensions on FileSpan { return subspan(scanner.position).trimLeft(); } + /// Returns a span covering the text after this span and before [other]. + /// + /// Throws an [ArgumentError] if [other.start] isn't on or after `this.end` in + /// the same file. + FileSpan between(FileSpan other) { + if (sourceUrl != other.sourceUrl) { + throw ArgumentError("$this and $other are in different files."); + } else if (end.offset > other.start.offset) { + throw ArgumentError("$this isn't before $other."); + } + + return file.span(end.offset, other.start.offset); + } + + /// Returns a span covering the text from the beginning of this span to the + /// beginning of [inner]. + /// + /// Throws an [ArgumentError] if [inner] isn't fully within this span. + FileSpan before(FileSpan inner) { + if (sourceUrl != inner.sourceUrl) { + throw ArgumentError("$this and $inner are in different files."); + } else if (inner.start.offset < start.offset || + inner.end.offset > end.offset) { + throw ArgumentError("$inner isn't inside $this."); + } + + return file.span(start.offset, inner.start.offset); + } + + /// Returns a span covering the text from the end of [inner] to the end of + /// this span. + /// + /// Throws an [ArgumentError] if [inner] isn't fully within this span. + FileSpan after(FileSpan inner) { + if (sourceUrl != inner.sourceUrl) { + throw ArgumentError("$this and $inner are in different files."); + } else if (inner.start.offset < start.offset || + inner.end.offset > end.offset) { + throw ArgumentError("$inner isn't inside $this."); + } + + return file.span(inner.end.offset, end.offset); + } + /// Whether this [FileSpan] contains the [target] FileSpan. /// /// Validates the FileSpans to be in the same file and for the [target] to be diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index 79b0f44db..d0e36c360 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -2541,6 +2541,8 @@ final class _EvaluateVisitor throw _exception("Undefined function.", node.span); } + // Note that the list of calculation functions is also tracked in + // lib/src/visitor/is_plain_css_safe.dart. switch (node.name.toLowerCase()) { case "min" || "max" || "round" || "abs" when node.arguments.named.isEmpty && @@ -3628,7 +3630,7 @@ final class _EvaluateVisitor if (warnForColor && namesByColor.containsKey(result)) { var alternative = BinaryOperationExpression( BinaryOperator.plus, - StringExpression(Interpolation([""], interpolation.span), + StringExpression(Interpolation.plain("", interpolation.span), quotes: true), expression); _warn( diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index a555ac8cd..8ee041bee 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: ca67afd2df1c970eb887d4a24c7fe838c2aaec60 +// Checksum: 6f39aea0955dc6ea8669496ea2270387b61b8aa7 // // ignore_for_file: unused_import @@ -2517,6 +2517,8 @@ final class _EvaluateVisitor throw _exception("Undefined function.", node.span); } + // Note that the list of calculation functions is also tracked in + // lib/src/visitor/is_plain_css_safe.dart. switch (node.name.toLowerCase()) { case "min" || "max" || "round" || "abs" when node.arguments.named.isEmpty && @@ -3593,7 +3595,7 @@ final class _EvaluateVisitor if (warnForColor && namesByColor.containsKey(result)) { var alternative = BinaryOperationExpression( BinaryOperator.plus, - StringExpression(Interpolation([""], interpolation.span), + StringExpression(Interpolation.plain("", interpolation.span), quotes: true), expression); _warn( diff --git a/lib/src/visitor/is_calculation_safe.dart b/lib/src/visitor/is_calculation_safe.dart new file mode 100644 index 000000000..9d2e406fc --- /dev/null +++ b/lib/src/visitor/is_calculation_safe.dart @@ -0,0 +1,86 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:charcode/charcode.dart'; + +import '../ast/sass.dart'; +import '../util/nullable.dart'; +import '../value.dart'; +import 'interface/expression.dart'; + +// We could use [AstSearchVisitor] to implement this more tersely, but that +// would default to returning `true` if we added a new expression type and +// forgot to update this class. + +/// A visitor that determines whether an expression is valid in a calculation +/// context. +/// +/// This should be used through [Expression.isCalculationSafe]. +class IsCalculationSafeVisitor implements ExpressionVisitor { + const IsCalculationSafeVisitor(); + + bool visitBinaryOperationExpression(BinaryOperationExpression node) => + (const { + BinaryOperator.times, + BinaryOperator.dividedBy, + BinaryOperator.plus, + BinaryOperator.minus + }).contains(node.operator) && + (node.left.accept(this) || node.right.accept(this)); + + bool visitBooleanExpression(BooleanExpression node) => false; + + bool visitColorExpression(ColorExpression node) => false; + + bool visitFunctionExpression(FunctionExpression node) => true; + + bool visitInterpolatedFunctionExpression( + InterpolatedFunctionExpression node) => + true; + + bool visitIfExpression(IfExpression node) => true; + + bool visitListExpression(ListExpression node) => + node.separator == ListSeparator.space && + !node.hasBrackets && + node.contents.length > 1 && + node.contents.every((expression) => expression.accept(this)); + + bool visitMapExpression(MapExpression node) => false; + + bool visitNullExpression(NullExpression node) => false; + + bool visitNumberExpression(NumberExpression node) => true; + + bool visitParenthesizedExpression(ParenthesizedExpression node) => + node.expression.accept(this); + + bool visitSelectorExpression(SelectorExpression node) => false; + + bool visitStringExpression(StringExpression node) { + if (node.hasQuotes) return false; + + // Exclude non-identifier constructs that are parsed as [StringExpression]s. + // We could just check if they parse as valid identifiers, but this is + // cheaper. + var text = node.text.initialPlain; + return + // !important + !text.startsWith("!") && + // ID-style identifiers + !text.startsWith("#") && + // Unicode ranges + text.codeUnitAtOrNull(1) != $plus && + // url() + text.codeUnitAtOrNull(3) != $lparen; + } + + bool visitSupportsExpression(SupportsExpression node) => false; + + bool visitUnaryOperationExpression(UnaryOperationExpression node) => false; + + bool visitValueExpression(ValueExpression node) => false; + + bool visitVariableExpression(VariableExpression node) => true; +} diff --git a/lib/src/visitor/replace_expression.dart b/lib/src/visitor/replace_expression.dart index 43d93eebc..8c06423de 100644 --- a/lib/src/visitor/replace_expression.dart +++ b/lib/src/visitor/replace_expression.dart @@ -128,5 +128,6 @@ mixin ReplaceExpressionVisitor implements ExpressionVisitor { Interpolation( interpolation.contents .map((node) => node is Expression ? node.accept(this) : node), + interpolation.spans, interpolation.span); } diff --git a/lib/src/visitor/source_interpolation.dart b/lib/src/visitor/source_interpolation.dart new file mode 100644 index 000000000..aadb3e1e5 --- /dev/null +++ b/lib/src/visitor/source_interpolation.dart @@ -0,0 +1,136 @@ +// 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 '../ast/sass.dart'; +import '../interpolation_buffer.dart'; +import '../util/span.dart'; +import 'interface/expression.dart'; + +/// A visitor that builds an [Interpolation] that evaluates to the same text as +/// the given expression. +/// +/// This should be used through [Expression.asInterpolation]. +class SourceInterpolationVisitor implements ExpressionVisitor { + /// The buffer to which content is added each time this visitor visits an + /// expression. + /// + /// This is set to null if the visitor encounters a node that's not valid CSS + /// with interpolations. + InterpolationBuffer? buffer = InterpolationBuffer(); + + void visitBinaryOperationExpression(BinaryOperationExpression node) => + buffer = null; + + void visitBooleanExpression(BooleanExpression node) => buffer = null; + + void visitColorExpression(ColorExpression node) => + buffer?.write(node.span.text); + + void visitFunctionExpression(FunctionExpression node) => buffer = null; + + void visitInterpolatedFunctionExpression( + InterpolatedFunctionExpression node) { + buffer?.addInterpolation(node.name); + _visitArguments(node.arguments); + } + + /// Visits the positional arguments in [arguments] with [visitor], if it's + /// valid interpolated plain CSS. + void _visitArguments(ArgumentInvocation arguments, + [ExpressionVisitor? visitor]) { + if (arguments.named.isNotEmpty || arguments.rest != null) return; + + if (arguments.positional.isEmpty) { + buffer?.write(arguments.span.text); + return; + } + + buffer?.write(arguments.span.before(arguments.positional.first.span).text); + _writeListAndBetween(arguments.positional, visitor); + buffer?.write(arguments.span.after(arguments.positional.last.span).text); + } + + void visitIfExpression(IfExpression node) => buffer = null; + + void visitListExpression(ListExpression node) { + if (node.contents.length <= 1 && !node.hasBrackets) { + buffer = null; + return; + } + + if (node.hasBrackets && node.contents.isEmpty) { + buffer?.write(node.span.text); + return; + } + + if (node.hasBrackets) { + buffer?.write(node.span.before(node.contents.first.span).text); + } + _writeListAndBetween(node.contents); + + if (node.hasBrackets) { + buffer?.write(node.span.after(node.contents.last.span).text); + } + } + + void visitMapExpression(MapExpression node) => buffer = null; + + void visitNullExpression(NullExpression node) => buffer = null; + + void visitNumberExpression(NumberExpression node) => + buffer?.write(node.span.text); + + void visitParenthesizedExpression(ParenthesizedExpression node) => + buffer = null; + + void visitSelectorExpression(SelectorExpression node) => buffer = null; + + void visitStringExpression(StringExpression node) { + if (node.text.isPlain) { + buffer?.write(node.span.text); + return; + } + + for (var i = 0; i < node.text.contents.length; i++) { + var span = node.text.spanForElement(i); + switch (node.text.contents[i]) { + case Expression expression: + if (i == 0) buffer?.write(node.span.before(span).text); + buffer?.add(expression, span); + if (i == node.text.contents.length - 1) { + buffer?.write(node.span.after(span).text); + } + + case _: + buffer?.write(span); + } + } + } + + void visitSupportsExpression(SupportsExpression node) => buffer = null; + + void visitUnaryOperationExpression(UnaryOperationExpression node) => + buffer = null; + + void visitValueExpression(ValueExpression node) => buffer = null; + + void visitVariableExpression(VariableExpression node) => buffer = null; + + /// Visits each expression in [expression] with [visitor], and writes whatever + /// text is between them to [buffer]. + void _writeListAndBetween(List expressions, + [ExpressionVisitor? visitor]) { + visitor ??= this; + + Expression? lastExpression; + for (var expression in expressions) { + if (lastExpression != null) { + buffer?.write(lastExpression.span.between(expression.span).text); + } + expression.accept(visitor); + if (buffer == null) return; + lastExpression = expression; + } + } +} diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index ff13b901d..f6272c31e 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.5 + +* Add support for parsing the `@supports` rule. + ## 0.2.4 * No user-visible changes. diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index 4f18be58b..f48fbcd06 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -121,6 +121,57 @@ declare namespace SassInternal { readonly selector: Interpolation; } + class SupportsRule extends ParentStatement { + readonly condition: SupportsCondition; + } + + type SupportsCondition = + | SupportsAnything + | SupportsDeclaration + | SupportsInterpolation + | SupportsNegation + | SupportsOperation; + + class SupportsAnything extends SassNode { + readonly contents: Interpolation; + + toInterpolation(): Interpolation; + } + + class SupportsDeclaration extends SassNode { + readonly name: Interpolation; + readonly value: Interpolation; + + toInterpolation(): Interpolation; + } + + class SupportsFunction extends SassNode { + readonly name: Interpolation; + readonly arguments: Interpolation; + + toInterpolation(): Interpolation; + } + + class SupportsInterpolation extends SassNode { + readonly expression: Expression; + + toInterpolation(): Interpolation; + } + + class SupportsNegation extends SassNode { + readonly condition: SupportsCondition; + + toInterpolation(): Interpolation; + } + + class SupportsOperation extends SassNode { + readonly left: SupportsCondition; + readonly right: SupportsCondition; + readonly operator: 'and' | 'or'; + + toInterpolation(): Interpolation; + } + class Expression extends SassNode { accept(visitor: ExpressionVisitor): T; } @@ -162,6 +213,7 @@ export type MediaRule = SassInternal.MediaRule; export type SilentComment = SassInternal.SilentComment; export type Stylesheet = SassInternal.Stylesheet; export type StyleRule = SassInternal.StyleRule; +export type SupportsRule = SassInternal.SupportsRule; export type Interpolation = SassInternal.Interpolation; export type Expression = SassInternal.Expression; export type BinaryOperationExpression = SassInternal.BinaryOperationExpression; @@ -179,6 +231,7 @@ export interface StatementVisitorObject { visitMediaRule(node: MediaRule): T; visitSilentComment(node: SilentComment): T; visitStyleRule(node: StyleRule): T; + visitSupportsRule(node: SupportsRule): T; } export interface ExpressionVisitorObject { diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index 905a3c072..b742aff8d 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -163,6 +163,18 @@ const visitor = sassInternal.createStatementVisitor({ }, visitSilentComment: inner => new SassComment(undefined, inner), visitStyleRule: inner => new Rule(undefined, inner), + visitSupportsRule: inner => { + const rule = new GenericAtRule({ + name: 'supports', + paramsInterpolation: new Interpolation( + undefined, + inner.condition.toInterpolation() + ), + source: new LazySource(inner), + }); + appendInternalChildren(rule, inner.children); + return rule; + }, }); /** Appends parsed versions of `internal`'s children to `container`. */ diff --git a/pkg/sass-parser/lib/src/statement/supports-rule.test.ts b/pkg/sass-parser/lib/src/statement/supports-rule.test.ts new file mode 100644 index 000000000..1de844e5d --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/supports-rule.test.ts @@ -0,0 +1,209 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {GenericAtRule, scss} from '../..'; + +describe('a @supports rule', () => { + let node: GenericAtRule; + + describe('SupportsAnything', () => { + beforeEach( + () => + void (node = scss.parse('@supports ( foo $&#{bar} baz) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => { + const params = node.paramsInterpolation!; + expect(params.nodes[0]).toBe('( foo $&'); + expect(params).toHaveStringExpression(1, 'bar'); + expect(params.nodes[2]).toBe(' baz)'); + }); + + it('has matching params', () => + expect(node.params).toBe('( foo $&#{bar} baz)')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports ( foo $&#{bar} baz) {}')); + }); + + describe('SupportsDeclaration', () => { + describe('with plain CSS on both sides', () => { + beforeEach( + () => + void (node = scss.parse('@supports ( foo : bar, #abc, []) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + '( foo : bar, #abc, [])' + )); + + it('has matching params', () => + expect(node.params).toBe('( foo : bar, #abc, [])')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports ( foo : bar, #abc, []) {}')); + }); + + // Can't test this until variable expressions are supported + describe.skip('with raw SassScript on both sides', () => { + beforeEach( + () => + void (node = scss.parse('@supports ($foo: $bar) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + '(#{$foo}: #{$bar})' + )); + + it('has matching params', () => expect(node.params).toBe('($foo: $bar)')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports ($foo: $bar) {}')); + }); + + describe('with explicit interpolation on both sides', () => { + beforeEach( + () => + void (node = scss.parse('@supports (#{"foo"}: #{"bar"}) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + '(#{"foo"}: #{"bar"})' + )); + + it('has matching params', () => + expect(node.params).toBe('(#{"foo"}: #{"bar"})')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports (#{"foo"}: #{"bar"}) {}')); + }); + }); + + describe('SupportsFunction', () => { + beforeEach( + () => + void (node = scss.parse('@supports foo#{"bar"}(baz &*^ #{"bang"}) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + 'foo#{"bar"}(baz &*^ #{"bang"})' + )); + + it('has matching params', () => + expect(node.params).toBe('foo#{"bar"}(baz &*^ #{"bang"})')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe( + '@supports foo#{"bar"}(baz &*^ #{"bang"}) {}' + )); + }); + + describe('SupportsInterpolation', () => { + beforeEach( + () => + void (node = scss.parse('@supports #{"bar"} {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', '#{"bar"}')); + + it('has matching params', () => expect(node.params).toBe('#{"bar"}')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports #{"bar"} {}')); + }); + + describe('SupportsNegation', () => { + describe('with one space', () => { + beforeEach( + () => + void (node = scss.parse('@supports not #{"bar"} {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + 'not #{"bar"}' + )); + + it('has matching params', () => expect(node.params).toBe('not #{"bar"}')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports not #{"bar"} {}')); + }); + + describe('with a comment', () => { + beforeEach( + () => + void (node = scss.parse('@supports not/**/#{"bar"} {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + 'not/**/#{"bar"}' + )); + + it('has matching params', () => + expect(node.params).toBe('not/**/#{"bar"}')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports not/**/#{"bar"} {}')); + }); + }); + + describe('SupportsOperation', () => { + beforeEach( + () => + void (node = scss.parse('@supports (#{"foo"} or #{"bar"}) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + '(#{"foo"} or #{"bar"})' + )); + + it('has matching params', () => + expect(node.params).toBe('(#{"foo"} or #{"bar"})')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports (#{"foo"} or #{"bar"}) {}')); + }); +}); diff --git a/pkg/sass-parser/package.json b/pkg/sass-parser/package.json index b0797a86c..2ffe572dc 100644 --- a/pkg/sass-parser/package.json +++ b/pkg/sass-parser/package.json @@ -1,6 +1,6 @@ { "name": "sass-parser", - "version": "0.2.4", + "version": "0.2.5", "description": "A PostCSS-compatible wrapper of the official Sass parser", "repository": "sass/sass", "author": "Google Inc.", diff --git a/pkg/sass-parser/test/setup.ts b/pkg/sass-parser/test/setup.ts index 35b17de62..66cb2f8d0 100644 --- a/pkg/sass-parser/test/setup.ts +++ b/pkg/sass-parser/test/setup.ts @@ -12,6 +12,16 @@ import 'jest-extended'; import {Interpolation, StringExpression} from '../lib'; +/** + * Like {@link MatcherContext.printReceived}, but with special handling for AST + * nodes. + */ +function printValue(self: MatcherContext, value: unknown): string { + return value instanceof postcss.Node + ? value.toString() + : self.utils.printReceived(value); +} + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace jest { @@ -58,7 +68,8 @@ function toHaveInterpolation( if (typeof actual !== 'object' || !actual || !(property in actual)) { return { message: () => - `expected ${this.utils.printReceived( + `expected ${printValue( + this, actual )} to have a property ${this.utils.printExpected(property)}`, pass: false, @@ -67,15 +78,14 @@ function toHaveInterpolation( const actualValue = (actual as Record)[property]; const message = (): string => - `expected (${this.utils.printReceived( - actual - )}).${property} ${this.utils.printReceived( + `expected (${printValue(this, actual)}).${property} ${printValue( + this, actualValue )} to be an Interpolation with value ${this.utils.printExpected(value)}`; if ( !(actualValue instanceof Interpolation) || - actualValue.asPlain !== value + actualValue.toString() !== value ) { return { message, @@ -86,9 +96,8 @@ function toHaveInterpolation( if (actualValue.parent !== actual) { return { message: () => - `expected (${this.utils.printReceived( - actual - )}).${property} ${this.utils.printReceived( + `expected (${printValue(this, actual)}).${property} ${printValue( + this, actualValue )} to have the correct parent`, pass: false, @@ -129,7 +138,8 @@ function toHaveStringExpression( if (typeof actual !== 'object' || !actual || !(property in actual)) { return { message: () => - `expected ${this.utils.printReceived( + `expected ${printValue( + this, actual )} to have a property ${this.utils.printExpected(property)}`, pass: false, @@ -140,12 +150,13 @@ function toHaveStringExpression( if (index !== null) actualValue = (actualValue as unknown[])[index]; const message = (): string => { - let message = `expected (${this.utils.printReceived(actual)}).${property}`; + let message = `expected (${printValue(this, actual)}).${property}`; if (index !== null) message += `[${index}]`; return ( message + - ` ${this.utils.printReceived( + ` ${printValue( + this, actualValue )} to be a StringExpression with value ${this.utils.printExpected(value)}` ); @@ -164,9 +175,8 @@ function toHaveStringExpression( if (actualValue.parent !== actual) { return { message: () => - `expected (${this.utils.printReceived( - actual - )}).${property} ${this.utils.printReceived( + `expected (${printValue(this, actual)}).${property} ${printValue( + this, actualValue )} to have the correct parent`, pass: false, diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index 3d9f81d7f..0fc3ae22e 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,17 @@ +## 13.0.0 + +* The `Interpolation()` constructor now takes an additional `List` + spans argument which cover the `#{}` for expression elements. + +* Added a new `Interpolation.plain()` constructor for interpolations that only + contain a single plain-text string. + +* Added `Interpolation.spanForElement()` which returns the span that covers a + single element of `contents`. + +* `InterpolationBuffer.add()` now takes a `FileSpan` that covers the `#{}` + around the expression. + ## 12.0.5 * No user-visible changes. diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index 2a7e30b20..c31385bc5 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: 12.0.5 +version: 13.0.0 description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass